jdaugherty commented on code in PR #16019:
URL: https://github.com/apache/grails-core/pull/16019#discussion_r3684785698
##########
grails-doc/src/en/guide/plugins/hookingIntoRuntimeConfiguration.adoc:
##########
@@ -119,6 +119,148 @@ class I18nGrailsPlugin extends Plugin {
A plugin may override either `doWithSpring()` or `doWithSpring(BeanBuilder)`,
but not both. The closure-returning `doWithSpring()` remains fully supported
for backwards compatibility. Defining both forms in the same plugin is an
error: the plugin will fail to load with an exception, since the two forms are
alternatives and defining both indicates an incomplete migration between them.
Consolidate the bean definitions into a single form.
+==== Compiling Bean Definitions into a Spring Boot AutoConfiguration
+
+
+`beanRegistrar()` and `doWithSpring` register beans at a single, fixed point
in startup: before Spring Boot processes any
`{springbootapi}org/springframework/boot/autoconfigure/AutoConfiguration.html[AutoConfiguration]`.
That is enough for a bean to win against a Boot default guarded by
`@ConditionalOnMissingBean`, but it cannot express ordering *relative to a
specific* auto-configuration — running before `WebMvcAutoConfiguration` but
after `MessageSourceAutoConfiguration`, say, regardless of where those two land
in Boot's overall sort. Only a real `AutoConfiguration` class can declare that
ordering, via `@AutoConfiguration(before = ..., after = ...)`.
+
+The link:{api}grails/compiler/beans/GrailsBeans.html[GrailsBeans] annotation
lets you author bean definitions with DSL syntax similar to the classic Beans
DSL, while compiling them at build time into genuine `@Bean` factory methods on
a plain `AutoConfiguration` class — no closures, and nothing DSL-specific,
survive into the compiled bytecode. Declare a class whose `beans` property is a
closure of `bean(["name", ] Type) { ... }` statements:
+
+NOTE: On a `*GrailsPlugin.groovy` plugin descriptor or an application's
`Application` class the annotation is implicit — a `beans` property is compiled
automatically, the same way `doWithSpring` and `watchedResources` are
conventions rather than annotated members. `@GrailsBeans` is written out only
on a standalone class, as in the first example below. A class with no `beans`
property is untouched.
+
+[source,groovy]
+----
+import grails.compiler.beans.GrailsBeans
+import org.springframework.boot.autoconfigure.AutoConfiguration
+import
org.springframework.boot.autoconfigure.context.MessageSourceAutoConfiguration
+import org.springframework.boot.webmvc.autoconfigure.WebMvcAutoConfiguration
+import org.springframework.context.MessageSource
+import
org.springframework.context.support.ReloadableResourceBundleMessageSource
+import org.springframework.web.servlet.i18n.CookieLocaleResolver
+import org.springframework.web.servlet.i18n.LocaleChangeInterceptor
+
+@GrailsBeans
+@AutoConfiguration(before = [MessageSourceAutoConfiguration,
WebMvcAutoConfiguration])
+class I18nBeans {
+
+ def beans = {
+ bean('messageSource', ReloadableResourceBundleMessageSource) {
+ new ReloadableResourceBundleMessageSource(basename:
'WEB-INF/grails-app/i18n/messages')
+ }
+
+ bean('localeResolver',
CookieLocaleResolver).conditionalOnMissingBean(CookieLocaleResolver) {
+ new CookieLocaleResolver('locale')
+ }
+
+ bean('localeChangeInterceptor', LocaleChangeInterceptor) {
MessageSource messageSource ->
+ new LocaleChangeInterceptor(paramName: 'lang')
+ }
+ }
+}
+----
+
+Each `bean(["name", ] Type)` statement compiles to a public method returning
the declared type and annotated `@Bean("name")`. The generated method's name is
an implementation detail — Spring resolves the bean by its `@Bean("name")`
value, never by the method name. It matches the bean name when that is a valid
Java identifier not already taken by an existing member (declared, inherited,
or defined elsewhere in the DSL — a bean named `toString` will not override
`Object.toString()`); otherwise a synthesized `<type>$N` name (`service$0`,
`service$1`, ...) is used instead, so any legal, non-blank Spring bean name
compiles:
+
+* The bean name defaults to the decapitalized simple class name when omitted.
+* The factory closure itself may be omitted when a bean is nothing but its own
no-argument construction: `bean(XmlDataBindingSourceCreator)` declares exactly
what `bean(XmlDataBindingSourceCreator) { new XmlDataBindingSourceCreator() }`
does, without restating the type twice. It takes an explicit name
(`bean('xmlCreator', XmlDataBindingSourceCreator)`) and chains the qualifiers
below just as the closure form does (`bean(Widget).primary().lazy()`). The
declared type is the one constructed, so it must be concrete and have an
accessible no-argument constructor; for an interface or abstract type, give the
bean a body naming the implementation.
+* Leaving the closure's *body* empty means the same thing for a bean with
dependencies: the parameters say what is injected, and the constructor call is
generated. `bean('validateableConstraintsEvaluator',
DefaultConstraintEvaluatorFactoryBean).lazy() { List<MessageSource>
messageSources, @Qualifier('grailsDomainClassMappingContext') MappingContext
mappingContext, GrailsApplication grailsApplication -> }` generates a method
with exactly those three parameters whose body is `new
DefaultConstraintEvaluatorFactoryBean(messageSources, mappingContext,
grailsApplication)` — the parameters in the order written. Which constructor
that selects is decided by the compiler from the parameter types, exactly as
for a body you wrote yourself, so nothing here depends on the declared type's
other constructors. A body is still needed whenever construction is more than
passing the parameters straight through — `new
ConfigProperties(grailsApplication.config)`, or anything followed by `.tap { }`.
+* The same bean name may be declared by more than one `bean(...)` statement —
the standard Spring Boot pattern for mutually exclusive variants of one bean,
such as the framework's own `grailsUrlConverter` selected by
`@ConditionalOnProperty` — provided every declaration with the name carries its
own discriminating condition (such as `.annotate(ConditionalOnProperty, ...)`),
so that at most one of them registers at runtime. Duplicating a bean name
without that is a compile-time error, since Spring would keep the first
definition and silently skip the rest.
+* Chaining `.conditionalOnMissingBean(...)` adds a `@ConditionalOnMissingBean`
annotation to the generated method, matching Spring Boot's usual back-off
semantics. It takes positional types (`.conditionalOnMissingBean(Greeter)`),
the annotation's own named attributes (`.conditionalOnMissingBean(name:
'localeResolver', search: SearchStrategy.CURRENT)`), or both. With no arguments
at all it compiles to the bare annotation, letting Spring Boot infer the
back-off type from the method's return type — the recommended form when a bean
simply backs off its own type, since
`bean(AvailableLocaleResolver).conditionalOnMissingBean()` already says
everything `.conditionalOnMissingBean(AvailableLocaleResolver)` would repeat.
+* Chaining `.conditionalOnMissingBeanName(...)` is the name-based counterpart:
"register this bean unless a bean with *this bean's name* already exists". The
condition's `name` member is set from the bean's own (explicit or
convention-derived) name, so
`bean(MessageSource).conditionalOnMissingBeanName(search:
SearchStrategy.CURRENT) { ... }` states `messageSource` once and it feeds both
`@Bean` and the condition — the two strings the explicit form has to keep in
sync cannot diverge. It accepts the annotation's other attributes (`search:`,
`ignored:`, ...) but rejects `name:` and types, which would contradict its
purpose; use `.conditionalOnMissingBean(...)` for those.
+* Chaining `.primary()`, `.lazy()`, and/or `.scope("name")` adds `@Primary`,
`@Lazy`, and `@Scope("name")` respectively.
+* Chaining `.staticMethod()` makes the generated factory method `static` —
Spring's recommended shape for `BeanFactoryPostProcessor` and
`BeanPostProcessor` beans, which must be creatable without instantiating their
declaring configuration class. A static bean's closure cannot reference
`field(...)` or `method(...)` members, since those are instance members; under
`@CompileStatic` that mistake is a compile error.
+* Chaining `.annotate(AnnotationType[, attr: value, ...])` attaches any other
single-valued annotation directly — any `@Conditional*` (built-in or a custom
`Condition`), `@Order`, `@DependsOn`, or anything else a hand-written `@Bean`
method could carry. It's repeatable, so several different annotations can be
chained onto the same bean: `bean('special', Special).annotate(Order, value:
1).annotate(ConditionalOnProperty, prefix: 'myapp', name: 'enabled',
havingValue: 'true') { ... }`.
+* Any combination of the above can be chained together, in any order — for
example `bean('slowGreeter', SlowGreeter).lazy().scope('prototype') { ... }`.
+* Typed closure parameters (`{ MessageSource messageSource -> ... }`) become
the generated method's parameters, so other beans are injected the same way a
hand-written `@Bean` method would declare them — and a parameter-level
annotation such as `{ @Qualifier('special') Greeter g -> ... }` carries
straight through too, since the closure's own parameters become the generated
method's parameters directly.
+
+Two more statement kinds can appear inside `beans { }` alongside `bean(...)`,
for state and logic shared across bean methods — the same role a hand-written
`@Configuration` class's own fields and private methods play:
+
+* `field(["name", ] Type)`, optionally chained with `.value(...)` and/or
(repeatably) `.annotate(AnnotationType[, attr: value, ...])`, declares a
private field on the generated class. The usual case is injected configuration,
and `.value(...)` covers it directly: `field('encoding',
String).value('grails.views.gsp.encoding', 'UTF-8')` compiles to
`@Value("${grails.views.gsp.encoding:UTF-8}")`. The two-argument form takes a
config key and default — and because the DSL builds the placeholder itself, the
key may be a bare constant reference (`.value(Settings.GSP_VIEW_ENCODING,
'UTF-8')`), the one shape a directly-written annotation value can't accept. The
one-argument form takes a bare config key with no default —
`.value('grails.web.linkGenerator.useCache')` compiles to
`@Value("${grails.web.linkGenerator.useCache}")` — while a string already
containing a `${...}` placeholder or a `#{...}` SpEL expression passes through
verbatim: `.value('#{T(java.lang.Runtime).getRuntime().avail
ableProcessors()}')`.
+* `method(["name", ] Type) { ... }`, chainable with `.annotate(...)` only
(`.value(...)` is field-specific), declares a private helper method the same
way `bean(...)` declares a public one — typed closure parameters become method
parameters, and the closure body becomes the method body.
+
+Both are ordinary private members of the generated class, so a `bean(...)`
closure reads a `field(...)` or calls a `method(...)` exactly like a
hand-written `@Bean` method would reference a sibling field or method on its
own class:
+
+[source,groovy]
+----
+field('suffix', String).value('myapp.greeting-suffix', '!')
+
+method('buildGreeting', String) { String name ->
+ "Hello, ${name}${suffix}"
+}
+
+bean('greeting', Greeting) {
+ new Greeting(buildGreeting('World'))
+}
+----
+
+Because the result is an ordinary `AutoConfiguration` class, it must be
registered the same way any Spring Boot auto-configuration is: listed in
`META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`.
Modules built as part of this project can apply the
`org.apache.grails.buildsrc.autoconfiguration-imports` Gradle convention plugin
to generate that file automatically, by scanning the module's own compiled
classes for `@AutoConfiguration` at build time. Projects outside this build
register the class the same way any hand-written `AutoConfiguration` is
registered — by listing it in that file directly.
+
+NOTE: `@GrailsBeans` does not require extending `Plugin` at all — it produces
a plain Spring Boot `AutoConfiguration`, so it can be used by any Spring Boot
module, not just a Grails plugin descriptor. Applications can use it too,
including directly on the `Application` class — where no imports-file
registration is needed, since Spring Boot processes the application class
itself — see link:spring.html#springdslAdditional[Configuring Additional
Beans]. Within a plugin, reach for it specifically when a bean's registration
needs to be ordered against a particular other auto-configuration; otherwise
`beanRegistrar()` remains the recommended way to register beans from a `Plugin`.
+
+The DSL still covers a narrower surface than `doWithSpring(BeanBuilder)`:
+
+* Each top-level statement inside `beans { }` must be exactly `bean(...)`,
`field(...)`, or `method(...)`, with their respective chaining — an `if`, a
`for` loop, or any other imperative Groovy directly inside `beans { }` is a
compile-time error, not a runtime one. Note that this is about the DSL's own
top-level statements, not the bodies of `bean(...)`/`method(...)` closures
themselves, which accept arbitrary Groovy, subject only to what is reachable
from the generated class (see the last bullet below) — and
`field(...)`/`method(...)` already cover the common reasons to want shared
state or logic in the first place.
+* `.annotate(...)` attribute values must be compile-time constants: strings,
numbers, booleans, class literals, enum constants, or arrays of these. A bare
constant *reference* counts, and so does a *concatenation* of them —
`.annotate(Value, value: '${' + Settings.I18N_LOCALE_RESOLVER + ':session}')`
folds, `@CompileStatic` included, the same way `.value(...)` folds its
arguments. The one shape that does not fold is a `static final` declared on a
Groovy *class*: that is a property with a generated getter, and
`@CompileStatic` rewrites the reference into a call to it. An interface's
constants are real `public static final` fields and fold correctly — which is
every `grails.config.Settings` key. An attribute that itself takes a nested
annotation as its value isn't supported.
Review Comment:
The exception this bullet still carries is stale in the same way the
concatenation one was, and for the same reason: `foldStringValue` resolves the
value during canonicalization, before `@CompileStatic`'s transform runs, so
there is no reference left for it to rewrite into a getter call.
A `static final` on a Groovy *class* does fold. All four combinations, with
`@CompileStatic` on the annotated class:
| constant owner | `.value(KEY, 'x')` | `.annotate(Value, value: '${' + KEY
+ ':y}')` |
| --- | --- | --- |
| same-compilation-unit Groovy class | `${probe.key:x}` | `${probe.key:y}` |
| already-compiled Groovy class | `${precompiled.probe.key:x}` |
`${precompiled.probe.key:y}` |
A bare reference as the whole attribute value folds too — `.annotate(Value,
value: PrecompiledKeys.KEY)` gives `precompiled.probe.key`.
`findStaticFinalField` reads the `FieldNode`, and a Groovy property's
backing field is still `static final` with its initial expression intact, so
the property-versus-field distinction the bullet turns on no longer has any
effect. As written it steers readers away from a shape that works — which is
what the earlier version of this bullet did with `Settings`.
##########
grails-beans-dsl/src/main/java/org/grails/compiler/beans/GrailsBeansASTTransformation.java:
##########
@@ -0,0 +1,1354 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.grails.compiler.beans;
+
+import java.beans.Introspector;
+import java.lang.reflect.Modifier;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import javax.lang.model.SourceVersion;
+
+import groovy.transform.CompilationUnitAware;
+import groovy.transform.CompileStatic;
+import groovy.transform.TypeChecked;
+import org.apache.groovy.util.BeanUtils;
+import org.codehaus.groovy.GroovyBugError;
+import org.codehaus.groovy.ast.ASTNode;
+import org.codehaus.groovy.ast.AnnotatedNode;
+import org.codehaus.groovy.ast.AnnotationNode;
+import org.codehaus.groovy.ast.ClassHelper;
+import org.codehaus.groovy.ast.ClassNode;
+import org.codehaus.groovy.ast.FieldNode;
+import org.codehaus.groovy.ast.MethodNode;
+import org.codehaus.groovy.ast.Parameter;
+import org.codehaus.groovy.ast.PropertyNode;
+import org.codehaus.groovy.ast.expr.ArgumentListExpression;
+import org.codehaus.groovy.ast.expr.BinaryExpression;
+import org.codehaus.groovy.ast.expr.ClassExpression;
+import org.codehaus.groovy.ast.expr.ClosureExpression;
+import org.codehaus.groovy.ast.expr.ConstantExpression;
+import org.codehaus.groovy.ast.expr.ConstructorCallExpression;
+import org.codehaus.groovy.ast.expr.Expression;
+import org.codehaus.groovy.ast.expr.ListExpression;
+import org.codehaus.groovy.ast.expr.MapEntryExpression;
+import org.codehaus.groovy.ast.expr.MapExpression;
+import org.codehaus.groovy.ast.expr.MethodCallExpression;
+import org.codehaus.groovy.ast.expr.PropertyExpression;
+import org.codehaus.groovy.ast.expr.VariableExpression;
+import org.codehaus.groovy.ast.stmt.BlockStatement;
+import org.codehaus.groovy.ast.stmt.EmptyStatement;
+import org.codehaus.groovy.ast.stmt.ExpressionStatement;
+import org.codehaus.groovy.ast.stmt.ReturnStatement;
+import org.codehaus.groovy.ast.stmt.Statement;
+import org.codehaus.groovy.control.CompilationUnit;
+import org.codehaus.groovy.control.CompilePhase;
+import org.codehaus.groovy.control.SourceUnit;
+import org.codehaus.groovy.syntax.SyntaxException;
+import org.codehaus.groovy.syntax.Types;
+import org.codehaus.groovy.transform.ASTTransformation;
+import org.codehaus.groovy.transform.GroovyASTTransformation;
+import org.codehaus.groovy.transform.StaticTypesTransformation;
+import org.codehaus.groovy.transform.sc.StaticCompileTransformation;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.boot.autoconfigure.AutoConfigureAfter;
+import org.springframework.boot.autoconfigure.AutoConfigureBefore;
+import org.springframework.boot.autoconfigure.AutoConfigureOrder;
+import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
+import
org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import
org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.context.annotation.ComponentScans;
+import org.springframework.context.annotation.Conditional;
+import org.springframework.context.annotation.Import;
+import org.springframework.context.annotation.ImportResource;
+import org.springframework.context.annotation.Lazy;
+import org.springframework.context.annotation.Primary;
+import org.springframework.context.annotation.PropertySource;
+import org.springframework.context.annotation.PropertySources;
+import org.springframework.context.annotation.Scope;
+
+/**
+ * Rewrites the {@code beans} closure DSL on a {@link
grails.compiler.beans.GrailsBeans}-annotated
+ * class into real {@code @Bean} factory methods, at compile time.
+ *
+ * <p>Recognises three kinds of top-level statement inside the {@code beans}
closure:
+ * <ul>
+ * <li>{@code bean(["name", ] Type) { ... }}, optionally chained with any
combination of
+ * {@code .conditionalOnMissingBean(...)} (positional types, named annotation
attributes, or bare),
+ * {@code .conditionalOnMissingBeanName(...)} (backs off by this bean's own
name, set
+ * automatically), {@code .primary()}, {@code .lazy()},
+ * {@code .scope("name")}, {@code .staticMethod()} (a static factory method,
for
+ * {@code BeanFactoryPostProcessor}/{@code BeanPostProcessor} beans), and
(repeatably)
+ * {@code .annotate(AnnotationType[, attr: value, ...])}. Synthesises a public
method, returning
+ * the declared type, annotated {@code
@org.springframework.context.annotation.Bean("name")} plus
+ * whichever qualifiers were chained. Its parameters are always the DSL
closure's own, annotations
+ * included. Its body is the closure's, except where that body is empty - or
the closure is omitted
+ * altogether - in which case a {@code new Type(...)} call over those same
parameters is synthesised
+ * instead, leaving the compiler to select the constructor from their types
exactly as it would for a
+ * body written out by hand. The generated method's name is an implementation
detail: it matches the
+ * bean name when that is a usable Java identifier not already taken by an
existing or generated
+ * member, and falls back to a synthesized {@code <type>$N} name otherwise (a
non-identifier name
+ * like {@code "my-service"}, a reserved keyword, or a collision - a bean
named {@code toString}
+ * never overrides {@code Object.toString()}) - Spring resolves the bean by
its {@code @Bean("name")}
+ * value either way, never by the method name. One bean name may be declared
by several
+ * {@code bean(...)} statements when every declaration carries its own
discriminating condition
+ * (see {@link #validateSharedBeanNames}).</li>
+ * <li>{@code field(["name", ] Type)}, optionally chained with {@code
.value(...)} (config
+ * injection: key + default, a bare key, or a verbatim placeholder/SpEL
string) and/or (repeatably)
+ * {@code .annotate(AnnotationType[, attr: value, ...])}. Declares a private
field on the
+ * generated class, for state shared across bean methods.</li>
+ * <li>{@code method(["name", ] Type) { ... }}, chainable with {@code
.annotate(...)} only
+ * ({@code .value(...)} is field-specific).
+ * Declares a private helper method on the generated class, for logic shared
across bean methods,
+ * lifted from the DSL closure the same way {@code bean(...)} is.</li>
+ * </ul>
+ *
+ * <p>Fields and helper methods declared this way are ordinary private members
of the generated
+ * class - {@code bean(...)} closures reference them the same way a
hand-written {@code @Bean}
+ * method would reference a sibling field or method on its {@code
@Configuration} class. The
+ * {@code beans} property itself is removed so no closure survives into the
compiled class.
+ *
+ * <p>When the annotated class extends {@code grails.plugins.Plugin}, the
generated members land
+ * on a new sibling class in the same package instead of on the plugin class
itself - named by
+ * swapping a {@code *GrailsPlugin} suffix for {@code AutoConfiguration}, or
appending
+ * {@code AutoConfiguration} otherwise. A {@code Plugin} subclass is
instantiated by
+ * {@code DefaultGrailsPlugin} via plain reflection, never as a Spring bean,
so it cannot carry
+ * {@code @Bean} methods or a meaningful {@code @AutoConfiguration} annotation
of its own.
+ * {@code @AutoConfiguration} and every annotation that gates or configures it
- the
+ * {@code @Conditional*} family, {@code @Import}/{@code
@ImportAutoConfiguration}/
+ * {@code @ImportResource}, {@code @ComponentScan}, {@code
@EnableConfigurationProperties},
+ * {@code @PropertySource}/{@code @PropertySources}, and
+ * {@code @AutoConfigureOrder}/{@code Before}/{@code After} - found on the
plugin class are moved
+ * onto the generated sibling, since that is the only place any of them has
any effect; annotations
+ * outside that set can be named explicitly via {@code
@GrailsBeans(moveAnnotations = ...)}. This lets a
+ * plugin author keep bean definitions in the familiar {@code
*GrailsPlugin.groovy} file while
+ * everything else about the plugin class - {@code doWithApplicationContext},
{@code onChange},
+ * {@code watchedResources}, etc. - continues to work exactly as it does today.
+ *
+ * <p>{@code @CompileStatic}/{@code @GrailsCompileStatic} on the plugin class
is propagated to the
+ * generated sibling. Since the sibling is created after Groovy schedules
local annotation
+ * transforms, this transformation invokes Groovy's static-compilation
transform directly after
+ * generating the sibling's members. This is the same approach used by other
Grails AST transforms
+ * that generate code after local transform discovery.
+ */
+@GroovyASTTransformation(phase = CompilePhase.CANONICALIZATION)
+public class GrailsBeansASTTransformation implements ASTTransformation,
CompilationUnitAware {
+
+ private static final String BEANS_PROPERTY = "beans";
+ private static final String BEAN_CALL = "bean";
+ private static final String FIELD_CALL = "field";
+ private static final String METHOD_CALL = "method";
+ private static final Set<String> ROOT_STATEMENT_CALL_NAMES =
Set.of(BEAN_CALL, FIELD_CALL, METHOD_CALL);
+ private static final String CONDITIONAL_ON_MISSING_BEAN_CALL =
"conditionalOnMissingBean";
+ private static final String CONDITIONAL_ON_MISSING_BEAN_NAME_CALL =
"conditionalOnMissingBeanName";
+ private static final String PRIMARY_CALL = "primary";
+ private static final String LAZY_CALL = "lazy";
+ private static final String SCOPE_CALL = "scope";
+ private static final String STATIC_METHOD_CALL = "staticMethod";
+ private static final String ANNOTATE_CALL = "annotate";
+ private static final String VALUE_CALL = "value";
+ private static final Set<String> BEAN_QUALIFIER_CALL_NAMES = Set.of(
+ CONDITIONAL_ON_MISSING_BEAN_CALL,
CONDITIONAL_ON_MISSING_BEAN_NAME_CALL,
+ PRIMARY_CALL, LAZY_CALL, SCOPE_CALL, STATIC_METHOD_CALL,
ANNOTATE_CALL);
+ // field(...) and method(...) declare plain class members, not beans -
bean-specific
+ // qualifiers don't apply; .value(...) (@Value config injection) is
field-only.
+ private static final Set<String> FIELD_QUALIFIER_CALL_NAMES =
Set.of(ANNOTATE_CALL, VALUE_CALL);
+ private static final Set<String> METHOD_QUALIFIER_CALL_NAMES =
Set.of(ANNOTATE_CALL);
+ private static final Set<String> ALL_QUALIFIER_CALL_NAMES = Set.of(
+ CONDITIONAL_ON_MISSING_BEAN_CALL,
CONDITIONAL_ON_MISSING_BEAN_NAME_CALL,
+ PRIMARY_CALL, LAZY_CALL, SCOPE_CALL, STATIC_METHOD_CALL,
ANNOTATE_CALL, VALUE_CALL);
+ private static final String PLUGIN_SUPERCLASS_NAME =
"grails.plugins.Plugin";
+ private static final String GRAILS_PLUGIN_SUFFIX = "GrailsPlugin";
+ private static final String AUTO_CONFIGURATION_SUFFIX =
"AutoConfiguration";
+ private static final String AUTO_CONFIGURATION_NAME_MEMBER =
"autoConfigurationName";
+ private static final String MOVE_ANNOTATIONS_MEMBER = "moveAnnotations";
+
+ private CompilationUnit compilationUnit;
+
+ @Override
+ public void setCompilationUnit(CompilationUnit compilationUnit) {
+ this.compilationUnit = compilationUnit;
+ }
+
+ @Override
+ public void visit(ASTNode[] nodes, SourceUnit source) {
+ AnnotationNode grailsBeansAnnotation = (AnnotationNode) nodes[0];
+ ClassNode classNode = (ClassNode) nodes[1];
+ PropertyNode beansProperty = classNode.getProperty(BEANS_PROPERTY);
+ if (beansProperty == null) {
+ addError(classNode, source, "@GrailsBeans requires a 'beans'
property initialised to a closure");
+ return;
+ }
+
+ Expression initialExpression = beansProperty.getInitialExpression();
+ if (!(initialExpression instanceof ClosureExpression)) {
+ addError(beansProperty, source, "'beans' must be initialised to a
closure, e.g. beans = { ... }");
+ return;
+ }
+
+ // An empty block is a no-op, not an error - an empty @Configuration
class is legal in Spring
+ // and an empty resources.groovy is legal in Grails, so having nothing
to declare should not
+ // fail the build. Returning before the sibling is created matters:
generating it would leave
+ // a bean-less class holding the @AutoConfiguration and @Conditional*
annotations moved off
+ // the plugin, which is worse than doing nothing. Only the DSL
scaffolding is stripped.
+ List<Statement> statements = beanStatements((ClosureExpression)
initialExpression);
+ if (statements.isEmpty()) {
+ removeBeansProperty(classNode, beansProperty);
+ return;
+ }
+
+ boolean isPlugin = extendsGrailsPlugin(classNode);
+ if (!isPlugin) {
+ for (String pluginOnlyMember : new String[] {
AUTO_CONFIGURATION_NAME_MEMBER, MOVE_ANNOTATIONS_MEMBER }) {
+ if (grailsBeansAnnotation.getMember(pluginOnlyMember) != null)
{
+ addError(grailsBeansAnnotation, source, pluginOnlyMember +
" has no effect here: it only applies " +
+ "when @GrailsBeans is applied to a
grails.plugins.Plugin subclass, where the compiled beans " +
+ "land on a generated sibling class rather than on
" + classNode.getNameWithoutPackage() + " itself");
+ }
+ }
+ }
+
+ ClassNode beanMethodHost = isPlugin ?
+ createAutoConfigurationSibling(classNode,
grailsBeansAnnotation, source) : classNode;
+
+ Set<String> usedNames = existingMemberNames(beanMethodHost);
+ validateSharedBeanNames(statements, source);
+ // Two passes: field(...)/method(...) declare explicit member names,
so they are processed
+ // first (along with anything malformed, so every statement is still
processed exactly
+ // once) and bean(...) statements second. A bean's derived method name
then adapts to every
+ // explicitly-named member wherever it appears in the block -
reordering equivalent DSL
+ // statements must never change validity.
+ for (Statement statement : statements) {
+ if (!isBeanRootedStatement(statement)) {
+ processStatement(beanMethodHost, statement, source, usedNames);
+ }
+ }
+ for (Statement statement : statements) {
+ if (isBeanRootedStatement(statement)) {
+ processStatement(beanMethodHost, statement, source, usedNames);
+ }
+ }
+
+ if (beanMethodHost != classNode) {
+ applyStaticCompilation(classNode, beanMethodHost, source);
+ }
+
+ removeBeansProperty(classNode, beansProperty);
+ }
+
+ private void removeBeansProperty(ClassNode classNode, PropertyNode
beansProperty) {
+ classNode.getProperties().remove(beansProperty);
+ // removeField, not getFields().remove: the latter leaves ClassNode's
own fieldIndex entry
+ // behind, so another member still referring to 'beans' type-checks
and compiles to a
+ // getfield against a field that is never emitted, failing with
NoSuchFieldError at runtime.
+ classNode.removeField(BEANS_PROPERTY);
+ }
+
+ private boolean extendsGrailsPlugin(ClassNode classNode) {
+ for (ClassNode current = classNode.getSuperClass(); current != null;
current = current.getSuperClass()) {
+ if (PLUGIN_SUPERCLASS_NAME.equals(current.getName())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ // Annotations that only make sense on whatever class Spring Boot actually
evaluates as an
+ // auto-configuration - meaningless on a Plugin subclass, which is
instantiated by
+ // DefaultGrailsPlugin via plain reflection and never processed by Spring
as a bean. Matching
+ // is transitive through meta-annotations (see belongsOnSibling), so this
list only needs the
+ // "root" annotations - a composed annotation built on top of any of these
(e.g. a custom
+ // @ConditionalOnFeature meta-annotated with Spring Boot's own
@ConditionalOnProperty, or a
+ // custom @EnableSomething meta-annotated with @Import) is found
automatically.
+ private static final Set<String> SIBLING_ONLY_ANNOTATION_NAMES = Set.of(
+ AutoConfiguration.class.getName(),
AutoConfigureOrder.class.getName(),
+ AutoConfigureBefore.class.getName(),
AutoConfigureAfter.class.getName(),
+ Import.class.getName(), ImportAutoConfiguration.class.getName(),
ImportResource.class.getName(),
+ ComponentScan.class.getName(), ComponentScans.class.getName(),
+ EnableConfigurationProperties.class.getName(),
+ PropertySource.class.getName(), PropertySources.class.getName(),
+ Conditional.class.getName());
+
+ private ClassNode createAutoConfigurationSibling(ClassNode pluginClass,
AnnotationNode grailsBeansAnnotation, SourceUnit source) {
+ List<AnnotationNode> autoConfigurationAnnotations =
pluginClass.getAnnotations(ClassHelper.make(AutoConfiguration.class));
+ if (autoConfigurationAnnotations.isEmpty()) {
+ addError(pluginClass, source, "A Plugin class using @GrailsBeans
must also be annotated " +
+ "@AutoConfiguration (even with no before=/after=) -
otherwise the generated " +
+ defaultSiblingSimpleName(pluginClass) +
+ " class would never be processed by Spring Boot");
+ }
+
+ String siblingSimpleName = siblingSimpleName(pluginClass,
grailsBeansAnnotation, source);
+ String packageName = pluginClass.getPackageName();
+ String siblingName = (packageName == null || packageName.isEmpty()) ?
+ siblingSimpleName : packageName + "." + siblingSimpleName;
+ ClassNode sibling = new ClassNode(siblingName, Modifier.PUBLIC,
ClassHelper.OBJECT_TYPE);
+ // Without a position, anything Groovy later reports against a
generated node - a sibling
+ // name clash, a typo in .annotate(...) - is reported at line -1,
column -1.
+ sibling.setSourcePosition(pluginClass);
+ source.getAST().addClass(sibling);
+
+ // Matching annotations move entirely rather than being merely copied
- they have no effect
+ // where the author wrote them (see SIBLING_ONLY_ANNOTATION_NAMES).
+ Set<String> moveAnnotationNames =
parseMoveAnnotations(grailsBeansAnnotation, source);
+ List<AnnotationNode> siblingAnnotations = new ArrayList<>();
+ for (AnnotationNode annotation : pluginClass.getAnnotations()) {
+ if (belongsOnSibling(annotation.getClassNode(),
moveAnnotationNames)) {
+ siblingAnnotations.add(annotation);
+ }
+ }
+ sibling.addAnnotations(siblingAnnotations);
+ pluginClass.getAnnotations().removeAll(siblingAnnotations);
+
+ return sibling;
+ }
+
+ private Set<String> parseMoveAnnotations(AnnotationNode
grailsBeansAnnotation, SourceUnit source) {
+ Expression member =
grailsBeansAnnotation.getMember(MOVE_ANNOTATIONS_MEMBER);
+ if (member == null) {
+ return Set.of();
+ }
+ List<Expression> entries = member instanceof ListExpression ?
+ ((ListExpression) member).getExpressions() : List.of(member);
+ Set<String> names = new HashSet<>();
+ for (Expression entry : entries) {
+ if (!(entry instanceof ClassExpression)) {
+ addError(entry, source, "moveAnnotations entries must be
annotation class literals, " +
+ "e.g. @GrailsBeans(moveAnnotations =
[ComponentScan])");
+ continue;
+ }
+ ClassNode annotationType = ((ClassExpression) entry).getType();
+ if (!annotationType.isAnnotationDefinition()) {
+ addError(entry, source, "\"" + annotationType.getName() + "\"
is not an annotation type");
+ continue;
+ }
+ names.add(annotationType.getName());
+ }
+ return names;
+ }
+
+ // A *GrailsPlugin name swaps that suffix for AutoConfiguration
(I18nGrailsPlugin ->
+ // I18nAutoConfiguration - the name the hand-written class it replaces
would have had);
+ // anything else appends AutoConfiguration.
+ private String defaultSiblingSimpleName(ClassNode pluginClass) {
+ String simpleName = pluginClass.getNameWithoutPackage();
+ if (simpleName.endsWith(GRAILS_PLUGIN_SUFFIX) && simpleName.length() >
GRAILS_PLUGIN_SUFFIX.length()) {
+ return simpleName.substring(0, simpleName.length() -
GRAILS_PLUGIN_SUFFIX.length()) + AUTO_CONFIGURATION_SUFFIX;
+ }
+ return simpleName + AUTO_CONFIGURATION_SUFFIX;
+ }
+
+ private String siblingSimpleName(ClassNode pluginClass, AnnotationNode
grailsBeansAnnotation, SourceUnit source) {
+ String defaultName = defaultSiblingSimpleName(pluginClass);
+ Expression nameArg =
grailsBeansAnnotation.getMember(AUTO_CONFIGURATION_NAME_MEMBER);
+ if (nameArg == null) {
+ return defaultName;
+ }
+ Object nameValue = nameArg instanceof ConstantExpression ?
((ConstantExpression) nameArg).getValue() : null;
+ if (!(nameValue instanceof String)) {
+ addError(nameArg, source, "@GrailsBeans(autoConfigurationName =
...) requires a String literal");
+ return defaultName;
+ }
+ String name = (String) nameValue;
+ if (name.isBlank()) {
+ addError(nameArg, source, "@GrailsBeans(autoConfigurationName =
\"" + name + "\") must not be " +
+ "blank - omit the attribute entirely to use the default "
+ defaultName + " instead");
+ return defaultName;
+ }
+ if (!isValidJavaIdentifier(name)) {
+ addError(nameArg, source, "@GrailsBeans(autoConfigurationName =
\"" + name + "\") is not a valid " +
+ "name: it becomes the generated sibling's simple class
name, so it must be a valid Java identifier");
+ return defaultName;
+ }
+ return name;
+ }
+
+ private boolean belongsOnSibling(ClassNode annotationType, Set<String>
moveAnnotationNames) {
+ return belongsOnSibling(annotationType, moveAnnotationNames, new
HashSet<>());
+ }
+
+ // Recurses through meta-annotations rather than checking only one level,
since Spring's own
+ // composed-annotation convention is arbitrarily deep - e.g. a
project-specific
+ // @ConditionalOnFeature is typically meta-annotated with an existing
@ConditionalOnXxx (itself
+ // meta-annotated @Conditional), not with @Conditional directly. `visited`
guards against cycles
+ // and, since every annotation type transitively reaches common JDK
meta-annotations
+ // (@Retention, @Target, @Documented) from multiple paths, avoids
redundant re-exploration.
+ private boolean belongsOnSibling(ClassNode annotationType, Set<String>
moveAnnotationNames, Set<String> visited) {
+ if (!visited.add(annotationType.getName())) {
+ return false;
+ }
+ if (SIBLING_ONLY_ANNOTATION_NAMES.contains(annotationType.getName()) ||
+ moveAnnotationNames.contains(annotationType.getName())) {
+ return true;
+ }
+ for (AnnotationNode metaAnnotation : annotationType.getAnnotations()) {
+ if (belongsOnSibling(metaAnnotation.getClassNode(),
moveAnnotationNames, visited)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ // The bean bodies are lifted onto a class the normal transform pipeline
no longer visits, so
+ // whichever of these the author put on the plugin has to be re-applied
here or the bodies are
+ // silently left unchecked. @GrailsCompileStatic and @GrailsTypeChecked
need no handling of their
+ // own: @AnnotationCollector has already expanded them by canonicalization.
+ private void applyStaticCompilation(ClassNode pluginClass, ClassNode
sibling, SourceUnit source) {
+ if (compilationUnit == null) {
+ return;
+ }
+ if (applyStaticTypesTransformation(pluginClass, sibling, source,
+ CompileStatic.class, new StaticCompileTransformation())) {
+ return;
+ }
+ applyStaticTypesTransformation(pluginClass, sibling, source,
+ TypeChecked.class, new StaticTypesTransformation());
+ }
+
+ private boolean applyStaticTypesTransformation(ClassNode pluginClass,
ClassNode sibling, SourceUnit source,
+ Class<? extends java.lang.annotation.Annotation> annotationType,
StaticTypesTransformation transformation) {
+ List<AnnotationNode> annotations =
pluginClass.getAnnotations(ClassHelper.make(annotationType));
+ if (annotations.isEmpty()) {
+ return false;
+ }
+
+ AnnotationNode sourceAnnotation = annotations.get(0);
+ AnnotationNode siblingAnnotation = new
AnnotationNode(ClassHelper.make(annotationType));
+ sourceAnnotation.getMembers().forEach(siblingAnnotation::setMember);
+ siblingAnnotation.setSourcePosition(sourceAnnotation);
+ sibling.addAnnotation(siblingAnnotation);
+
+ transformation.setCompilationUnit(compilationUnit);
+ transformation.visit(new ASTNode[] { siblingAnnotation, sibling },
source);
+ return true;
+ }
+
+ private List<Statement> beanStatements(ClosureExpression dsl) {
+ Statement code = dsl.getCode();
+ if (code instanceof BlockStatement) {
+ return ((BlockStatement) code).getStatements();
+ }
+ List<Statement> single = new ArrayList<>();
+ single.add(code);
+ return single;
+ }
+
+ // Generated names must not collide with anything the host class already
has: its own fields
+ // and methods (in the standalone form the host is a real user-written
class), every method
+ // inherited through its full type graph - superclasses and interfaces
alike, since a bean
+ // named 'toString' or after an interface's default method must
synthesize, not override -
+ // and the GroovyObject methods Groovy itself adds at class generation.
+ private Set<String> existingMemberNames(ClassNode host) {
+ Set<String> names = new HashSet<>();
+ for (FieldNode field : host.getFields()) {
+ names.add(field.getName());
+ }
+ Set<String> visited = new HashSet<>();
+ collectMethodNames(host, names, visited);
+ collectMethodNames(ClassHelper.GROOVY_OBJECT_TYPE, names, visited);
+ return names;
+ }
+
+ private void collectMethodNames(ClassNode type, Set<String> names,
Set<String> visited) {
+ if (type == null || !visited.add(type.getName())) {
+ return;
+ }
+ for (MethodNode method : type.getMethods()) {
+ names.add(method.getName());
+ }
+ // A Groovy property's accessors are synthesized by the Verifier at
class generation, AFTER
+ // this transform runs, so they are not in getMethods() yet - reserve
the names they will
+ // occupy, or a same-named bean method would displace the real
accessor.
+ for (PropertyNode property : type.getProperties()) {
+ String capitalized = BeanUtils.capitalize(property.getName());
+ names.add("get" + capitalized);
+ names.add("set" + capitalized);
+ if (ClassHelper.boolean_TYPE.equals(property.getType()) ||
+ ClassHelper.Boolean_TYPE.equals(property.getType())) {
+ names.add("is" + capitalized);
+ }
+ }
+ collectMethodNames(type.getSuperClass(), names, visited);
+ for (ClassNode implemented : type.getInterfaces()) {
+ collectMethodNames(implemented, names, visited);
+ }
+ }
+
+ // Silent classification counterpart of processStatement's qualifier-chain
walk: descends to
+ // the root call without reporting anything, so malformed statements are
classified (not
+ // validated) here and still produce their usual errors when actually
processed.
+ private boolean isBeanRootedStatement(Statement statement) {
+ if (!(statement instanceof ExpressionStatement) ||
+ !(((ExpressionStatement) statement).getExpression() instanceof
MethodCallExpression)) {
+ return false;
+ }
+ MethodCallExpression call = (MethodCallExpression)
((ExpressionStatement) statement).getExpression();
+ while (!ROOT_STATEMENT_CALL_NAMES.contains(call.getMethodAsString()) &&
+ call.getObjectExpression() instanceof MethodCallExpression) {
+ call = (MethodCallExpression) call.getObjectExpression();
+ }
+ return BEAN_CALL.equals(call.getMethodAsString());
+ }
+
+ // A Spring bean name may be declared by more than one bean(...) statement
- the standard
+ // autoconfiguration pattern for mutually exclusive variants of one bean,
e.g. Grails' two
+ // "grailsUrlConverter" beans selected by @ConditionalOnProperty - but
only when every
+ // statement sharing the name carries a condition of its own that could
discriminate between
+ // them at runtime. Without one, the duplicates can never all take effect
(Spring keeps the
+ // first definition from a configuration class and silently skips the
rest), so the likeliest
+ // explanation is a copy-paste accident - rejected at compile time
instead. The shared-name
+ // back-off (.conditionalOnMissingBeanName(), or
.conditionalOnMissingBean() with no
+ // arguments) does not count: it is identical on every duplicate by
construction, so it can
+ // never tell them apart.
+ private void validateSharedBeanNames(List<Statement> statements,
SourceUnit source) {
+ Map<String, List<BeanNameUse>> usesByName = new LinkedHashMap<>();
+ for (Statement statement : statements) {
+ if (!isBeanRootedStatement(statement)) {
+ continue;
+ }
+ BeanNameUse use = parseBeanNameUse(
+ (MethodCallExpression) ((ExpressionStatement)
statement).getExpression());
+ if (use != null) {
+ usesByName.computeIfAbsent(use.beanName, key -> new
ArrayList<>()).add(use);
+ }
+ }
+ for (List<BeanNameUse> uses : usesByName.values()) {
+ if (uses.size() < 2) {
+ continue;
+ }
+ for (BeanNameUse use : uses) {
+ if (!use.conditioned) {
+ addError(use.baseCall, source, "\"" + use.beanName + "\"
is already used as the Spring " +
+ "bean name of another bean(...) statement -
declaring it more than once is only " +
+ "allowed when every declaration with the name
carries its own discriminating " +
+ "condition (e.g. .annotate(ConditionalOnProperty,
...)), so that at most one of " +
+ "them registers at runtime");
+ }
+ }
+ }
+ }
+
+ private static final class BeanNameUse {
+ private final String beanName;
+ private final MethodCallExpression baseCall;
+ private final boolean conditioned;
+
+ BeanNameUse(String beanName, MethodCallExpression baseCall, boolean
conditioned) {
+ this.beanName = beanName;
+ this.baseCall = baseCall;
+ this.conditioned = conditioned;
+ }
+ }
+
+ // Silent classification counterpart of processBeanStatement's parsing, in
the same spirit as
+ // isBeanRootedStatement: extracts the bean name and whether the statement
carries a
+ // discriminating condition, returning null for anything malformed - a
malformed statement
+ // still produces its usual errors when actually processed.
+ private BeanNameUse parseBeanNameUse(MethodCallExpression outerCall) {
+ List<MethodCallExpression> qualifierCalls = new ArrayList<>();
+ MethodCallExpression baseCall = outerCall;
+ while
(!ROOT_STATEMENT_CALL_NAMES.contains(baseCall.getMethodAsString())) {
+ if (!(baseCall.getObjectExpression() instanceof
MethodCallExpression)) {
+ return null;
+ }
+ qualifierCalls.add(baseCall);
+ baseCall = (MethodCallExpression) baseCall.getObjectExpression();
+ }
+ if (!BEAN_CALL.equals(baseCall.getMethodAsString())) {
+ return null;
+ }
+
+ List<Expression> args =
withoutTrailingClosure(flatten(baseCall.getArguments()), baseCall, outerCall);
+ if (args.isEmpty() || args.size() > 2 || !(args.get(args.size() - 1)
instanceof ClassExpression)) {
+ return null;
+ }
+ String name;
+ if (args.size() == 1) {
+ name = decapitalize(((ClassExpression)
args.get(0)).getType().getNameWithoutPackage());
+ }
+ else {
+ Object nameValue = args.get(0) instanceof ConstantExpression ?
+ ((ConstantExpression) args.get(0)).getValue() : null;
+ if (!(nameValue instanceof String)) {
+ return null;
+ }
+ name = (String) nameValue;
+ }
+ return new BeanNameUse(name, baseCall,
hasDiscriminatingCondition(qualifierCalls, outerCall));
+ }
+
+ private boolean hasDiscriminatingCondition(List<MethodCallExpression>
qualifierCalls, MethodCallExpression outerCall) {
+ for (MethodCallExpression qualifierCall : qualifierCalls) {
+ String qualifierName = qualifierCall.getMethodAsString();
+ List<Expression> args =
withoutTrailingClosure(flatten(qualifierCall.getArguments()), qualifierCall,
outerCall);
+ if (CONDITIONAL_ON_MISSING_BEAN_CALL.equals(qualifierName) &&
discriminatesByType(args)) {
+ return true;
+ }
+ if (ANNOTATE_CALL.equals(qualifierName)) {
+ for (Expression arg : args) {
+ if (arg instanceof ClassExpression &&
isConditionalAnnotation(((ClassExpression) arg).getType())) {
+ return true;
+ }
+ }
+ }
+ }
+ return false;
+ }
+
+ // @ConditionalOnMissingBean attributes that say nothing about a *type*.
Where duplicates share
+ // one bean name - the only situation validateSharedBeanNames runs in - a
name: or search: is
+ // identical on each of them and so can no more tell them apart than the
bare form can.
+ private static final Set<String>
NON_DISCRIMINATING_MISSING_BEAN_ATTRIBUTES = Set.of("name", "search");
+
+ private boolean discriminatesByType(List<Expression> args) {
+ boolean sawSomething = false;
+ for (Expression arg : args) {
+ if (arg instanceof ClassExpression) {
+ return true;
+ }
+ if (!(arg instanceof MapExpression)) {
+ // an argument shape this method does not model - stay lenient
rather than reject
+ return true;
+ }
+ for (MapEntryExpression entry : ((MapExpression)
arg).getMapEntryExpressions()) {
+ Object key = entry.getKeyExpression() instanceof
ConstantExpression ?
+ ((ConstantExpression)
entry.getKeyExpression()).getValue() : null;
+ if (!(key instanceof String) ||
!NON_DISCRIMINATING_MISSING_BEAN_ATTRIBUTES.contains(key)) {
+ return true;
+ }
+ sawSomething = true;
+ }
+ }
+ return !sawSomething && !args.isEmpty();
+ }
+
+ private List<Expression> withoutTrailingClosure(List<Expression> args,
MethodCallExpression call,
+ MethodCallExpression outerCall) {
+ if (call == outerCall && !args.isEmpty() && args.get(args.size() - 1)
instanceof ClosureExpression) {
+ return args.subList(0, args.size() - 1);
+ }
+ return args;
+ }
+
+ private boolean isConditionalAnnotation(ClassNode annotationType) {
+ return isConditionalAnnotation(annotationType, new HashSet<>());
+ }
+
+ // The same transitive meta-annotation walk belongsOnSibling does, against
@Conditional alone:
+ // every @ConditionalOnXxx - Spring Boot's own and arbitrarily-deeply
composed custom ones -
+ // eventually reaches @Conditional through its meta-annotations.
+ private boolean isConditionalAnnotation(ClassNode annotationType,
Set<String> visited) {
+ if (!visited.add(annotationType.getName())) {
+ return false;
+ }
+ if (Conditional.class.getName().equals(annotationType.getName())) {
+ return true;
+ }
+ for (AnnotationNode metaAnnotation : annotationType.getAnnotations()) {
+ if (isConditionalAnnotation(metaAnnotation.getClassNode(),
visited)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private void processStatement(ClassNode classNode, Statement statement,
SourceUnit source, Set<String> usedNames) {
+ if (!(statement instanceof ExpressionStatement) ||
+ !(((ExpressionStatement) statement).getExpression() instanceof
MethodCallExpression)) {
+ addError(statement, source, "Each 'beans' statement must be a
bean(...), field(...), or method(...) call");
+ return;
+ }
+
+ MethodCallExpression outerCall = (MethodCallExpression)
((ExpressionStatement) statement).getExpression();
+
+ // Walk from the outermost (last-written) call back to the
bean(...)/field(...)/method(...)
+ // call at the root, collecting any chained qualifiers along the way.
+ List<MethodCallExpression> qualifierCalls = new ArrayList<>();
+ MethodCallExpression baseCall = outerCall;
+ while
(!ROOT_STATEMENT_CALL_NAMES.contains(baseCall.getMethodAsString())) {
+ if
(!ALL_QUALIFIER_CALL_NAMES.contains(baseCall.getMethodAsString()) ||
+ !(baseCall.getObjectExpression() instanceof
MethodCallExpression)) {
+ addError(statement, source, "Expected bean([\"name\", ] Type)
{ ... }, field([\"name\", ] Type), " +
+ "or method([\"name\", ] Type) { ... }, optionally
chained with qualifiers");
+ return;
+ }
+ qualifierCalls.add(0, baseCall);
+ baseCall = (MethodCallExpression) baseCall.getObjectExpression();
+ }
+
+ // The walk above stops at the first root call name it meets, so
without this a root
+ // statement chained onto another - field("suffix",
String).bean("greeter", String) { } -
+ // parses as one statement and the left-hand declaration is silently
dropped.
+ if (!baseCall.isImplicitThis() && baseCall.getObjectExpression()
instanceof MethodCallExpression) {
+ addError(statement, source, baseCall.getMethodAsString() + "(...)
cannot be chained onto " +
+ ((MethodCallExpression)
baseCall.getObjectExpression()).getMethodAsString() +
+ "(...) - each bean(...), field(...) and method(...)
declaration is its own statement");
+ return;
+ }
+
+ String rootName = baseCall.getMethodAsString();
+ boolean isBean = BEAN_CALL.equals(rootName);
+ Set<String> allowedQualifiers = isBean ? BEAN_QUALIFIER_CALL_NAMES :
+ FIELD_CALL.equals(rootName) ? FIELD_QUALIFIER_CALL_NAMES :
METHOD_QUALIFIER_CALL_NAMES;
+ for (MethodCallExpression qualifierCall : qualifierCalls) {
+ if
(!allowedQualifiers.contains(qualifierCall.getMethodAsString())) {
+ addError(qualifierCall, source, "." +
qualifierCall.getMethodAsString() + "(...) cannot be " +
+ "chained onto " + rootName + "(...)");
+ return;
+ }
+ }
+
+ // .annotate(...) is repeatable (once per distinct annotation type,
enforced when the
+ // annotation is actually attached below); every other qualifier is
single-use.
+ Set<String> seenQualifiers = new HashSet<>();
+ for (MethodCallExpression qualifierCall : qualifierCalls) {
+ String qualifierName = qualifierCall.getMethodAsString();
+ if (!ANNOTATE_CALL.equals(qualifierName) &&
!seenQualifiers.add(qualifierName)) {
+ addError(qualifierCall, source, "." + qualifierName + "(...)
may only be chained once per " +
+ rootName + "(...)");
+ return;
+ }
+ }
+
+ if (isBean) {
+ processBeanStatement(classNode, outerCall, baseCall,
qualifierCalls, source, usedNames);
+ }
+ else if (FIELD_CALL.equals(rootName)) {
+ processFieldStatement(classNode, baseCall, qualifierCalls, source,
usedNames);
+ }
+ else {
+ processMethodStatement(classNode, outerCall, baseCall,
qualifierCalls, source, usedNames);
+ }
+ }
+
+ private boolean registerName(String name, ASTNode location, SourceUnit
source, Set<String> usedNames, String errorSuffix) {
+ if (!usedNames.add(name)) {
+ addError(location, source, "\"" + name + "\" " + errorSuffix);
+ return false;
+ }
+ return true;
+ }
+
+ private void processBeanStatement(ClassNode classNode,
MethodCallExpression outerCall, MethodCallExpression baseCall,
+ List<MethodCallExpression> qualifierCalls, SourceUnit source,
Set<String> usedNames) {
+ // The factory closure is optional: bean(Type) with no body declares a
bean that is just its
+ // own no-argument construction, which is by far the most common shape
and reads as noise
+ // when spelled out as bean(Type) { new Type() }.
+ List<Expression> closureCallArgs = flatten(outerCall.getArguments());
+ ClosureExpression factory = !closureCallArgs.isEmpty() &&
+ closureCallArgs.get(closureCallArgs.size() - 1) instanceof
ClosureExpression ?
+ (ClosureExpression) closureCallArgs.get(closureCallArgs.size()
- 1) : null;
+
+ // When bean(...) is itself the outermost call (no qualifiers
chained), it carries the
+ // trailing closure as its own last argument - exclude it before
validating the [name, ] Type
+ // shape, since it was already validated above.
+ List<Expression> baseArgs = flatten(baseCall.getArguments());
+ if (factory != null && baseCall == outerCall && !baseArgs.isEmpty()) {
+ baseArgs = baseArgs.subList(0, baseArgs.size() - 1);
+ }
+
+ TypeAndName typeAndName = parseNameAndType(baseArgs, baseCall, source,
BEAN_CALL, false);
+ if (typeAndName == null) {
+ return;
+ }
+
+ ClassNode beanType = typeAndName.type.getType();
+ // A closure whose body is empty declares construction too, from its
own parameters: the
+ // parameters say what is injected, and the generated body is the
constructor call the author
+ // would otherwise have written out. bean(Type) { } with no parameters
is bean(Type).
+ boolean constructsDeclaredType = factory == null ||
isEmpty(factory.getCode());
+ if (constructsDeclaredType && (beanType.isInterface() ||
Modifier.isAbstract(beanType.getModifiers()))) {
+ addError(baseCall, source, "bean(" +
beanType.getNameWithoutPackage() + ") with no factory closure body " +
+ "constructs the declared type, which cannot be done for an
interface or abstract class - " +
+ "give it a body: bean(" + beanType.getNameWithoutPackage()
+ ") { new SomeImplementation() }");
+ return;
+ }
+
+ // The method name is an implementation detail - Spring resolves the
bean by its
+ // @Bean("name") value, never by the factory method's name - so a bean
name that isn't a
+ // usable Java identifier, or is already taken by an existing member,
synthesizes instead
+ // of erroring.
+ String javaMethodName = isValidJavaIdentifier(typeAndName.name) &&
!usedNames.contains(typeAndName.name) ?
+ typeAndName.name :
+ syntheticBeanMethodName(typeAndName.type.getType(), usedNames);
+ usedNames.add(javaMethodName);
+
+ Parameter[] beanParameters = factory == null ||
factory.getParameters() == null ?
+ Parameter.EMPTY_ARRAY : factory.getParameters();
+ Statement beanBody = constructsDeclaredType ?
Review Comment:
The synthesized `ConstructorCallExpression` is the one generated node in
this file with no `setSourcePosition(...)`, and it is the node most likely to
be the subject of a compile error: the constructor is selected from the
closure's parameter types, so a parameter list matching no constructor — after
someone adds an argument to the type's constructor, say — is the ordinary
failure mode of the empty-body form.
Without a position the failure never reaches the error collector as a
located message. Compiling the documented shape:
```groovy
class NeedsString {
NeedsString(String required) { }
}
@GrailsBeans
@CompileStatic
@AutoConfiguration
class ProbeWrongParams {
def beans = {
bean(NeedsString) { Integer n ->
}
}
}
```
In a normal build (assertions off):
```
org.codehaus.groovy.GroovyBugError: BUG! exception in phase 'class
generation' in source unit '...' at line -1 column -1
On receiver: NeedsString with message: <$constructor$> and arguments: (n)
StaticTypesCallSiteWriter#makeCallSite should not have been called. Call
site lacked method target for static compilation.
Please try to create a simple example reproducing this error and file a bug
report at https://issues.apache.org/jira/browse/GROOVY
```
Under `-ea` — which is what Gradle's test workers run with, so it is what a
plugin author sees from `./gradlew test` — it degrades further, to a bare
`java.lang.AssertionError: null` out of
`StaticCompilationVisitor.visitConstructorCallExpression`. The bodyless
`bean(NeedsString)` spelling fails identically. Same failure class as the
`getTypeClass()` one: an internal compiler error at line -1 in place of a
located message, with an invitation to file a Groovy bug.
The discrepancy is that the hand-written equivalent this is documented as
compiling to is diagnosed properly:
```
[Static type checking] - Cannot find matching constructor NeedsArgsHand().
Please check if the declared type is correct and if the method exists.
@ line 15, column 43.
bean(NeedsArgsHand) { new NeedsArgsHand() }
^
```
So "resolved from these argument types exactly as it would be for a
hand-written `new Type(...)`" holds for resolution but not for what the author
is told when it fails.
Copying the position onto the construction closes it. Verified against the
branch:
```java
ConstructorCallExpression construction =
new ConstructorCallExpression(beanType,
constructorArguments(beanParameters));
construction.setSourcePosition(baseCall);
```
after which the same fixture reports
```
[Static type checking] - Cannot find matching constructor
NeedsString(java.lang.Integer). Please check if the declared type is correct
and if the method exists.
@ line 15, column 21.
bean(NeedsString) { Integer n ->
^
```
pointing at the `bean(...)` statement. A test would help: the spec has no
case for a constructor mismatch in either the bodyless or the empty-body form,
which is why this got through.
##########
grails-doc/src/en/guide/upgrading/upgrading80x.adoc:
##########
@@ -1600,7 +1600,19 @@ This was introduced during the Grails 7.1 plugin-loading
changes and remains req
| `grails-domain-class`
| `ConstraintEvaluatorAdapter` and
`GrailsDomainClassAutoConfiguration.constraintsEvaluator(...)`
-| Code that imported the auto-configuration method should use the current
`validateableConstraintsEvaluator(...)` bean path and the datastore constraint
evaluator APIs.
+| Code that imported the auto-configuration method should use the current
`validateableConstraintsEvaluator(...)` bean path and the datastore constraint
evaluator APIs. The auto-configuration itself is now named
`org.grails.plugins.domain.DomainClassAutoConfiguration`, following the
`*GrailsPlugin` -> `*AutoConfiguration` convention; update any
`@EnableAutoConfiguration(exclude = ...)` or `spring.autoconfigure.exclude`
entry that referenced the old name.
+
+| `grails-core`
+| `org.grails.plugins.core.CoreAutoConfiguration`
+| Now `org.grails.plugins.CoreAutoConfiguration`. The class is generated
beside the plugin descriptor that declares its beans, and `CoreGrailsPlugin`
lives in `org.grails.plugins`, so the simple name is unchanged but the package
is not. Update any `@EnableAutoConfiguration(exclude = ...)`,
`spring.autoconfigure.exclude`, or `@AutoConfigureAfter(name = ...)` entry
naming the old package. Spring Boot only reports an invalid exclude for a class
still on the classpath, so a stale entry is silently ignored rather than
reported, and the beans it was meant to suppress are registered anyway.
+
+| `grails-cache`
+| `grails.plugin.cache.GrailsCacheAutoConfiguration`
+| Now `grails.plugin.cache.CacheAutoConfiguration`, following the
`*GrailsPlugin` -> `*AutoConfiguration` convention. Update any
`@EnableAutoConfiguration(exclude = ...)` or `spring.autoconfigure.exclude`
entry that referenced the old name; the same silent-ignore applies as above.
+
+| `grails-databinding`
+| `org.grails.plugins.databinding.DataBindingConfiguration`
+| Now `org.grails.plugins.databinding.DataBindingAutoConfiguration`. It was
registered as an auto-configuration while named only `*Configuration`; the name
now says what it is. Update any `@EnableAutoConfiguration(exclude = ...)` or
`spring.autoconfigure.exclude` entry that referenced the old name; the same
silent-ignore applies as above.
Review Comment:
These four rows cover every FQCN change, but not the one change in this PR
that alters which beans a running application ends up with.
The component-scan fix means an application whose working directory is not
the project root — a systemd unit, a container with its own `WORKDIR`, an app
server — starts registering `@Component`s under `grails.spring.bean.packages`
that it silently did not register before. The PR description says so plainly
("Applications that deploy this way will start getting beans they currently
miss"); the guide says nothing.
It arguably deserves a row here more than the renames do. A stale exclude
entry leaves a name that no longer resolves, which someone can at least search
for; this one changes the contents of a running context with nothing to grep
for, and in the direction of *adding* beans — a duplicate registration or an
unexpected `@Component` picking up a bean name is how it will present.
Worth noting while you are here that `grails.spring.bean.packages` appears
nowhere in `grails-doc` at all, so there is no existing page describing the
property for this to cross-reference.
##########
grails-beans-dsl/src/main/java/grails/compiler/beans/GrailsBeans.java:
##########
@@ -0,0 +1,129 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 grails.compiler.beans;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+import org.codehaus.groovy.transform.GroovyASTTransformationClass;
+
+/**
+ * Marks a class whose {@code beans} closure property is a bean-definition DSL
that should be
+ * compiled into real {@code @Bean} factory methods, so the class can serve as
a plain
+ * {@code @AutoConfiguration} with no closure DSL surviving into the compiled
bytecode.
+ *
+ * <p>The annotated class must declare a {@code beans} property initialised to
a closure whose
+ * statements are one of:
+ * <ul>
+ * <li>{@code bean(["name", ] Type) { ... }} - or {@code bean(["name", ]
Type)} with no body at all,
+ * which declares a bean that is simply {@code new Type()}, the most common
shape. A closure with
+ * parameters but an empty body means the same for a bean with dependencies:
the parameters say what
+ * is injected and the constructor call is generated from them, in the order
written, leaving the
+ * compiler to select the constructor from their types just as it would for a
hand-written body. Both
+ * forms require a concrete type; an interface or abstract type needs a body
naming the
+ * implementation. It chains with the qualifiers below exactly as the
+ * closure form does ({@code bean(Foo).lazy().conditionalOnMissingBean()}).
The closure form may be
+ * chained with any combination of
+ * {@code .conditionalOnMissingBean(...)} (positional types, the annotation's
own named
+ * attributes such as {@code name:}/{@code search:}, or no arguments at all to
let Spring infer
+ * the back-off type from the return type), {@code
.conditionalOnMissingBeanName(...)} (backs off
+ * by this bean's own name - set automatically, stated once - accepting the
annotation's other
+ * attributes but rejecting {@code name:} and types), {@code .primary()},
{@code .lazy()},
+ * {@code .scope("name")}, {@code .staticMethod()} (a {@code static} factory
method - Spring's
+ * recommended shape for {@code BeanFactoryPostProcessor}/{@code
BeanPostProcessor} beans, which
+ * must be creatable without instantiating their declaring configuration
class), and (repeatably)
+ * {@code .annotate(AnnotationType[, attr: value, ...])} - the last a generic
escape hatch
+ * attaching any other single-valued annotation. The closure body becomes the
generated method's
+ * body verbatim, and closure parameters become the generated method's
parameters (for
+ * constructor-style bean injection). The generated method's name is an
implementation detail:
+ * Spring resolves the bean by its {@code @Bean("name")} value, so a bean name
that isn't a valid
+ * Java identifier (e.g. {@code "my-service"}) simply gets a synthesized
{@code <type>$N} method
+ * name behind the scenes. The same bean name may even be declared by more
than one
+ * {@code bean(...)} statement - the standard Spring Boot pattern for mutually
exclusive variants
+ * of one bean - provided every declaration with the name carries its own
discriminating
+ * condition (e.g. {@code .annotate(ConditionalOnProperty, ...)}) so that at
most one of them
+ * registers at runtime.</li>
+ * <li>{@code field(["name", ] Type)}, optionally chained with {@code
.value(...)} and/or
+ * (repeatably) {@code .annotate(AnnotationType[, attr: value, ...])}.
Declares a private field on
+ * the generated class, for state shared across bean methods. The usual case
is injected
+ * configuration: {@code field("encoding",
String).value(Settings.GSP_VIEW_ENCODING, "UTF-8")}
+ * compiles to {@code @Value("${grails.views.gsp.encoding:UTF-8}")} - the
two-argument form takes
+ * a config key (a literal or a bare constant reference) plus default. The
one-argument form
+ * takes a bare config key with no default ({@code .value("app.encoding")}
compiles to
+ * {@code @Value("${app.encoding}")}), while a string already containing a
{@code ${...}}
+ * placeholder or {@code #{...}} SpEL expression passes through verbatim.</li>
+ * <li>{@code method(["name", ] Type) { ... }}, chainable with {@code
.annotate(...)} only
+ * ({@code .value(...)} is field-specific).
+ * Declares a private helper method on the generated class, for logic shared
across bean methods,
+ * lifted from the closure the same way {@code bean(...)} is.</li>
+ * </ul>
+ * When no name is given, one is derived from the type name following the
JavaBeans convention
+ * ({@link java.beans.Introspector#decapitalize(String)}).
+ *
+ * <p>The generated methods work on any class Spring processes as a
configuration source: a
+ * registered {@code @AutoConfiguration} or {@code @Configuration} class, or
the Spring Boot
+ * application class itself (e.g. a Grails {@code Application} class) - Spring
Boot reads
+ * {@code @Bean} methods directly off the class it is launched with, so no
further registration
+ * is needed there.
+ *
+ * <p>May also be applied to a {@code grails.plugins.Plugin} subclass, letting
bean definitions
+ * live in the familiar {@code *GrailsPlugin.groovy} file. In that case the
generated methods land
+ * on a new sibling class instead, named by the plugin-descriptor convention -
a {@code *GrailsPlugin}
+ * name swaps that suffix for {@code AutoConfiguration} ({@code
I18nGrailsPlugin} ->
+ * {@code I18nAutoConfiguration}), any other name appends it - or {@link
#autoConfigurationName}
+ * if given. A {@code Plugin} subclass is never processed by
+ * Spring as a bean, so {@code @AutoConfiguration} together with every
annotation that gates or
+ * configures it (the {@code @Conditional*} family, {@code @Import}/{@code
@ImportAutoConfiguration}/
+ * {@code @ImportResource}, {@code @ComponentScan}, {@code
@EnableConfigurationProperties},
+ * {@code @PropertySource}/{@code @PropertySources},
+ * {@code @AutoConfigureOrder}/{@code Before}/{@code After} - including any
composed annotation
+ * meta-annotated with one of these) found on the plugin class moves onto that
sibling, since none
+ * of them has any effect where the author wrote them. Annotations outside
that set can be moved
+ * explicitly via {@link #moveAnnotations}.
+ */
+@Retention(RetentionPolicy.RUNTIME)
Review Comment:
Carrying this forward from the scope thread so it does not get lost with it,
since that thread's actual question — one story for the nine declarations — is
settled.
Nothing reads `@GrailsBeans` at runtime. The transform consumes it at
canonicalization, and it is not among the annotations moved to the generated
sibling, so it stays on the source class where it is inert.
`RetentionPolicy.CLASS` would leave every compile-time use working while
keeping it out of the runtime image — which matters slightly more now that
`grails-core` declares `grails-beans-dsl` as `api` and so puts it, and its own
`spring-context`/`spring-boot-autoconfigure` `api` dependencies, on every
application's runtime classpath.
Not a blocker, and I am fine with either answer. If `RUNTIME` is deliberate
— leaving room for a runtime consumer later, say — a line on the annotation
saying so is enough to close it.
##########
grails-cache/src/main/groovy/grails/plugin/cache/CacheGrailsPlugin.groovy:
##########
@@ -22,16 +22,25 @@ package grails.plugin.cache
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j
-import org.springframework.beans.factory.BeanRegistrar
-import org.springframework.beans.factory.BeanRegistry
+import org.springframework.boot.autoconfigure.AutoConfiguration
+import
org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty
import org.springframework.cache.Cache
-import org.springframework.core.env.Environment
import grails.plugins.Plugin
import org.grails.plugin.cache.GrailsCacheManager
+/**
+ * Configures the cache plugin.
+ *
+ * <p>Every bean is contributed as auto-configuration so that one supplied by
the application or
+ * another plugin — for example a cache-provider plugin's {@code
grailsCacheManager} — makes the
+ * default back off cleanly instead of triggering a bean-definition override.
The whole set is gated
+ * on {@code grails.cache.enabled}.</p>
+ */
@Slf4j
@CompileStatic
+@AutoConfiguration
+@ConditionalOnBooleanProperty(name = 'grails.cache.enabled', matchIfMissing =
true)
Review Comment:
`@ConditionalOnBean(CachePluginConfiguration)` came off in this conversion
and nothing replaces it. The deleted `GrailsCacheAutoConfiguration` carried it
with the reason spelled out:
> Gated on the `CachePluginConfiguration` definition contributed by the
cache plugin descriptor's registrar (which runs before auto-configuration
conditions are evaluated), so the auto-configuration backs off entirely when
the plugin is not active — e.g. the jar is on the classpath but the plugin is
excluded — keeping it in lockstep with the descriptor.
The gate could not survive verbatim: `grailsCacheConfiguration` is now
declared in this same `beans` block rather than by the registrar, so it would
be conditioning on a bean the same class contributes. But the condition it
expressed has gone with it. With only `@ConditionalOnBooleanProperty(name =
'grails.cache.enabled')` left, a build with `grails-cache` on the classpath but
the plugin not loaded now gets `grailsCacheConfiguration`,
`grailsCacheAdminService`, `customCacheKeyGenerator` and `grailsCacheManager`
registered anyway — exactly the case the deleted comment named.
Is that deliberate, i.e. is jar-on-the-classpath now sufficient for the
cache beans, the way it is for an ordinary Boot starter? If so the class
javadoc should say it: it explains the back-off design carefully but is silent
on the lockstep that was dropped. If not, the descriptor no longer contributes
anything for the generated configuration to gate on, so restoring it needs a
different anchor — either way better decided explicitly than by omission.
Two smaller notes on the same move.
`environment.getProperty('grails.cache.enabled', Boolean, true)` and
`@ConditionalOnBooleanProperty` do not agree on non-canonical values: Spring's
`Boolean` conversion accepts `yes`/`on`/`1`, the condition matches only
`true`/`false`, so `grails.cache.enabled=no` used to disable the plugin and now
leaves it enabled. And the `log.warn('Cache plugin is disabled')` that
accompanied the old gate is gone, so disabling it is now silent.
##########
grails-beans-dsl/src/main/java/org/grails/compiler/beans/GrailsBeansASTTransformation.java:
##########
@@ -0,0 +1,1354 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.grails.compiler.beans;
+
+import java.beans.Introspector;
+import java.lang.reflect.Modifier;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import javax.lang.model.SourceVersion;
+
+import groovy.transform.CompilationUnitAware;
+import groovy.transform.CompileStatic;
+import groovy.transform.TypeChecked;
+import org.apache.groovy.util.BeanUtils;
+import org.codehaus.groovy.GroovyBugError;
+import org.codehaus.groovy.ast.ASTNode;
+import org.codehaus.groovy.ast.AnnotatedNode;
+import org.codehaus.groovy.ast.AnnotationNode;
+import org.codehaus.groovy.ast.ClassHelper;
+import org.codehaus.groovy.ast.ClassNode;
+import org.codehaus.groovy.ast.FieldNode;
+import org.codehaus.groovy.ast.MethodNode;
+import org.codehaus.groovy.ast.Parameter;
+import org.codehaus.groovy.ast.PropertyNode;
+import org.codehaus.groovy.ast.expr.ArgumentListExpression;
+import org.codehaus.groovy.ast.expr.BinaryExpression;
+import org.codehaus.groovy.ast.expr.ClassExpression;
+import org.codehaus.groovy.ast.expr.ClosureExpression;
+import org.codehaus.groovy.ast.expr.ConstantExpression;
+import org.codehaus.groovy.ast.expr.ConstructorCallExpression;
+import org.codehaus.groovy.ast.expr.Expression;
+import org.codehaus.groovy.ast.expr.ListExpression;
+import org.codehaus.groovy.ast.expr.MapEntryExpression;
+import org.codehaus.groovy.ast.expr.MapExpression;
+import org.codehaus.groovy.ast.expr.MethodCallExpression;
+import org.codehaus.groovy.ast.expr.PropertyExpression;
+import org.codehaus.groovy.ast.expr.VariableExpression;
+import org.codehaus.groovy.ast.stmt.BlockStatement;
+import org.codehaus.groovy.ast.stmt.EmptyStatement;
+import org.codehaus.groovy.ast.stmt.ExpressionStatement;
+import org.codehaus.groovy.ast.stmt.ReturnStatement;
+import org.codehaus.groovy.ast.stmt.Statement;
+import org.codehaus.groovy.control.CompilationUnit;
+import org.codehaus.groovy.control.CompilePhase;
+import org.codehaus.groovy.control.SourceUnit;
+import org.codehaus.groovy.syntax.SyntaxException;
+import org.codehaus.groovy.syntax.Types;
+import org.codehaus.groovy.transform.ASTTransformation;
+import org.codehaus.groovy.transform.GroovyASTTransformation;
+import org.codehaus.groovy.transform.StaticTypesTransformation;
+import org.codehaus.groovy.transform.sc.StaticCompileTransformation;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.boot.autoconfigure.AutoConfigureAfter;
+import org.springframework.boot.autoconfigure.AutoConfigureBefore;
+import org.springframework.boot.autoconfigure.AutoConfigureOrder;
+import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
+import
org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import
org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.context.annotation.ComponentScans;
+import org.springframework.context.annotation.Conditional;
+import org.springframework.context.annotation.Import;
+import org.springframework.context.annotation.ImportResource;
+import org.springframework.context.annotation.Lazy;
+import org.springframework.context.annotation.Primary;
+import org.springframework.context.annotation.PropertySource;
+import org.springframework.context.annotation.PropertySources;
+import org.springframework.context.annotation.Scope;
+
+/**
+ * Rewrites the {@code beans} closure DSL on a {@link
grails.compiler.beans.GrailsBeans}-annotated
+ * class into real {@code @Bean} factory methods, at compile time.
+ *
+ * <p>Recognises three kinds of top-level statement inside the {@code beans}
closure:
+ * <ul>
+ * <li>{@code bean(["name", ] Type) { ... }}, optionally chained with any
combination of
+ * {@code .conditionalOnMissingBean(...)} (positional types, named annotation
attributes, or bare),
+ * {@code .conditionalOnMissingBeanName(...)} (backs off by this bean's own
name, set
+ * automatically), {@code .primary()}, {@code .lazy()},
+ * {@code .scope("name")}, {@code .staticMethod()} (a static factory method,
for
+ * {@code BeanFactoryPostProcessor}/{@code BeanPostProcessor} beans), and
(repeatably)
+ * {@code .annotate(AnnotationType[, attr: value, ...])}. Synthesises a public
method, returning
+ * the declared type, annotated {@code
@org.springframework.context.annotation.Bean("name")} plus
+ * whichever qualifiers were chained. Its parameters are always the DSL
closure's own, annotations
+ * included. Its body is the closure's, except where that body is empty - or
the closure is omitted
+ * altogether - in which case a {@code new Type(...)} call over those same
parameters is synthesised
+ * instead, leaving the compiler to select the constructor from their types
exactly as it would for a
+ * body written out by hand. The generated method's name is an implementation
detail: it matches the
+ * bean name when that is a usable Java identifier not already taken by an
existing or generated
+ * member, and falls back to a synthesized {@code <type>$N} name otherwise (a
non-identifier name
+ * like {@code "my-service"}, a reserved keyword, or a collision - a bean
named {@code toString}
+ * never overrides {@code Object.toString()}) - Spring resolves the bean by
its {@code @Bean("name")}
+ * value either way, never by the method name. One bean name may be declared
by several
+ * {@code bean(...)} statements when every declaration carries its own
discriminating condition
+ * (see {@link #validateSharedBeanNames}).</li>
+ * <li>{@code field(["name", ] Type)}, optionally chained with {@code
.value(...)} (config
+ * injection: key + default, a bare key, or a verbatim placeholder/SpEL
string) and/or (repeatably)
+ * {@code .annotate(AnnotationType[, attr: value, ...])}. Declares a private
field on the
+ * generated class, for state shared across bean methods.</li>
+ * <li>{@code method(["name", ] Type) { ... }}, chainable with {@code
.annotate(...)} only
+ * ({@code .value(...)} is field-specific).
+ * Declares a private helper method on the generated class, for logic shared
across bean methods,
+ * lifted from the DSL closure the same way {@code bean(...)} is.</li>
+ * </ul>
+ *
+ * <p>Fields and helper methods declared this way are ordinary private members
of the generated
+ * class - {@code bean(...)} closures reference them the same way a
hand-written {@code @Bean}
+ * method would reference a sibling field or method on its {@code
@Configuration} class. The
+ * {@code beans} property itself is removed so no closure survives into the
compiled class.
+ *
+ * <p>When the annotated class extends {@code grails.plugins.Plugin}, the
generated members land
+ * on a new sibling class in the same package instead of on the plugin class
itself - named by
+ * swapping a {@code *GrailsPlugin} suffix for {@code AutoConfiguration}, or
appending
+ * {@code AutoConfiguration} otherwise. A {@code Plugin} subclass is
instantiated by
+ * {@code DefaultGrailsPlugin} via plain reflection, never as a Spring bean,
so it cannot carry
+ * {@code @Bean} methods or a meaningful {@code @AutoConfiguration} annotation
of its own.
+ * {@code @AutoConfiguration} and every annotation that gates or configures it
- the
+ * {@code @Conditional*} family, {@code @Import}/{@code
@ImportAutoConfiguration}/
+ * {@code @ImportResource}, {@code @ComponentScan}, {@code
@EnableConfigurationProperties},
+ * {@code @PropertySource}/{@code @PropertySources}, and
+ * {@code @AutoConfigureOrder}/{@code Before}/{@code After} - found on the
plugin class are moved
+ * onto the generated sibling, since that is the only place any of them has
any effect; annotations
+ * outside that set can be named explicitly via {@code
@GrailsBeans(moveAnnotations = ...)}. This lets a
+ * plugin author keep bean definitions in the familiar {@code
*GrailsPlugin.groovy} file while
+ * everything else about the plugin class - {@code doWithApplicationContext},
{@code onChange},
+ * {@code watchedResources}, etc. - continues to work exactly as it does today.
+ *
+ * <p>{@code @CompileStatic}/{@code @GrailsCompileStatic} on the plugin class
is propagated to the
+ * generated sibling. Since the sibling is created after Groovy schedules
local annotation
+ * transforms, this transformation invokes Groovy's static-compilation
transform directly after
+ * generating the sibling's members. This is the same approach used by other
Grails AST transforms
+ * that generate code after local transform discovery.
+ */
+@GroovyASTTransformation(phase = CompilePhase.CANONICALIZATION)
+public class GrailsBeansASTTransformation implements ASTTransformation,
CompilationUnitAware {
+
+ private static final String BEANS_PROPERTY = "beans";
+ private static final String BEAN_CALL = "bean";
+ private static final String FIELD_CALL = "field";
+ private static final String METHOD_CALL = "method";
+ private static final Set<String> ROOT_STATEMENT_CALL_NAMES =
Set.of(BEAN_CALL, FIELD_CALL, METHOD_CALL);
+ private static final String CONDITIONAL_ON_MISSING_BEAN_CALL =
"conditionalOnMissingBean";
+ private static final String CONDITIONAL_ON_MISSING_BEAN_NAME_CALL =
"conditionalOnMissingBeanName";
+ private static final String PRIMARY_CALL = "primary";
+ private static final String LAZY_CALL = "lazy";
+ private static final String SCOPE_CALL = "scope";
+ private static final String STATIC_METHOD_CALL = "staticMethod";
+ private static final String ANNOTATE_CALL = "annotate";
+ private static final String VALUE_CALL = "value";
+ private static final Set<String> BEAN_QUALIFIER_CALL_NAMES = Set.of(
+ CONDITIONAL_ON_MISSING_BEAN_CALL,
CONDITIONAL_ON_MISSING_BEAN_NAME_CALL,
+ PRIMARY_CALL, LAZY_CALL, SCOPE_CALL, STATIC_METHOD_CALL,
ANNOTATE_CALL);
+ // field(...) and method(...) declare plain class members, not beans -
bean-specific
+ // qualifiers don't apply; .value(...) (@Value config injection) is
field-only.
+ private static final Set<String> FIELD_QUALIFIER_CALL_NAMES =
Set.of(ANNOTATE_CALL, VALUE_CALL);
+ private static final Set<String> METHOD_QUALIFIER_CALL_NAMES =
Set.of(ANNOTATE_CALL);
+ private static final Set<String> ALL_QUALIFIER_CALL_NAMES = Set.of(
+ CONDITIONAL_ON_MISSING_BEAN_CALL,
CONDITIONAL_ON_MISSING_BEAN_NAME_CALL,
+ PRIMARY_CALL, LAZY_CALL, SCOPE_CALL, STATIC_METHOD_CALL,
ANNOTATE_CALL, VALUE_CALL);
+ private static final String PLUGIN_SUPERCLASS_NAME =
"grails.plugins.Plugin";
+ private static final String GRAILS_PLUGIN_SUFFIX = "GrailsPlugin";
+ private static final String AUTO_CONFIGURATION_SUFFIX =
"AutoConfiguration";
+ private static final String AUTO_CONFIGURATION_NAME_MEMBER =
"autoConfigurationName";
+ private static final String MOVE_ANNOTATIONS_MEMBER = "moveAnnotations";
+
+ private CompilationUnit compilationUnit;
+
+ @Override
+ public void setCompilationUnit(CompilationUnit compilationUnit) {
+ this.compilationUnit = compilationUnit;
+ }
+
+ @Override
+ public void visit(ASTNode[] nodes, SourceUnit source) {
+ AnnotationNode grailsBeansAnnotation = (AnnotationNode) nodes[0];
+ ClassNode classNode = (ClassNode) nodes[1];
+ PropertyNode beansProperty = classNode.getProperty(BEANS_PROPERTY);
+ if (beansProperty == null) {
+ addError(classNode, source, "@GrailsBeans requires a 'beans'
property initialised to a closure");
+ return;
+ }
+
+ Expression initialExpression = beansProperty.getInitialExpression();
+ if (!(initialExpression instanceof ClosureExpression)) {
+ addError(beansProperty, source, "'beans' must be initialised to a
closure, e.g. beans = { ... }");
+ return;
+ }
+
+ // An empty block is a no-op, not an error - an empty @Configuration
class is legal in Spring
+ // and an empty resources.groovy is legal in Grails, so having nothing
to declare should not
+ // fail the build. Returning before the sibling is created matters:
generating it would leave
+ // a bean-less class holding the @AutoConfiguration and @Conditional*
annotations moved off
+ // the plugin, which is worse than doing nothing. Only the DSL
scaffolding is stripped.
+ List<Statement> statements = beanStatements((ClosureExpression)
initialExpression);
+ if (statements.isEmpty()) {
+ removeBeansProperty(classNode, beansProperty);
+ return;
+ }
+
+ boolean isPlugin = extendsGrailsPlugin(classNode);
+ if (!isPlugin) {
+ for (String pluginOnlyMember : new String[] {
AUTO_CONFIGURATION_NAME_MEMBER, MOVE_ANNOTATIONS_MEMBER }) {
+ if (grailsBeansAnnotation.getMember(pluginOnlyMember) != null)
{
+ addError(grailsBeansAnnotation, source, pluginOnlyMember +
" has no effect here: it only applies " +
+ "when @GrailsBeans is applied to a
grails.plugins.Plugin subclass, where the compiled beans " +
+ "land on a generated sibling class rather than on
" + classNode.getNameWithoutPackage() + " itself");
+ }
+ }
+ }
+
+ ClassNode beanMethodHost = isPlugin ?
+ createAutoConfigurationSibling(classNode,
grailsBeansAnnotation, source) : classNode;
+
+ Set<String> usedNames = existingMemberNames(beanMethodHost);
+ validateSharedBeanNames(statements, source);
+ // Two passes: field(...)/method(...) declare explicit member names,
so they are processed
+ // first (along with anything malformed, so every statement is still
processed exactly
+ // once) and bean(...) statements second. A bean's derived method name
then adapts to every
+ // explicitly-named member wherever it appears in the block -
reordering equivalent DSL
+ // statements must never change validity.
+ for (Statement statement : statements) {
+ if (!isBeanRootedStatement(statement)) {
+ processStatement(beanMethodHost, statement, source, usedNames);
+ }
+ }
+ for (Statement statement : statements) {
+ if (isBeanRootedStatement(statement)) {
+ processStatement(beanMethodHost, statement, source, usedNames);
+ }
+ }
+
+ if (beanMethodHost != classNode) {
+ applyStaticCompilation(classNode, beanMethodHost, source);
+ }
+
+ removeBeansProperty(classNode, beansProperty);
+ }
+
+ private void removeBeansProperty(ClassNode classNode, PropertyNode
beansProperty) {
+ classNode.getProperties().remove(beansProperty);
+ // removeField, not getFields().remove: the latter leaves ClassNode's
own fieldIndex entry
+ // behind, so another member still referring to 'beans' type-checks
and compiles to a
+ // getfield against a field that is never emitted, failing with
NoSuchFieldError at runtime.
+ classNode.removeField(BEANS_PROPERTY);
+ }
+
+ private boolean extendsGrailsPlugin(ClassNode classNode) {
+ for (ClassNode current = classNode.getSuperClass(); current != null;
current = current.getSuperClass()) {
+ if (PLUGIN_SUPERCLASS_NAME.equals(current.getName())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ // Annotations that only make sense on whatever class Spring Boot actually
evaluates as an
+ // auto-configuration - meaningless on a Plugin subclass, which is
instantiated by
+ // DefaultGrailsPlugin via plain reflection and never processed by Spring
as a bean. Matching
+ // is transitive through meta-annotations (see belongsOnSibling), so this
list only needs the
+ // "root" annotations - a composed annotation built on top of any of these
(e.g. a custom
+ // @ConditionalOnFeature meta-annotated with Spring Boot's own
@ConditionalOnProperty, or a
+ // custom @EnableSomething meta-annotated with @Import) is found
automatically.
+ private static final Set<String> SIBLING_ONLY_ANNOTATION_NAMES = Set.of(
+ AutoConfiguration.class.getName(),
AutoConfigureOrder.class.getName(),
+ AutoConfigureBefore.class.getName(),
AutoConfigureAfter.class.getName(),
+ Import.class.getName(), ImportAutoConfiguration.class.getName(),
ImportResource.class.getName(),
+ ComponentScan.class.getName(), ComponentScans.class.getName(),
+ EnableConfigurationProperties.class.getName(),
+ PropertySource.class.getName(), PropertySources.class.getName(),
+ Conditional.class.getName());
+
+ private ClassNode createAutoConfigurationSibling(ClassNode pluginClass,
AnnotationNode grailsBeansAnnotation, SourceUnit source) {
+ List<AnnotationNode> autoConfigurationAnnotations =
pluginClass.getAnnotations(ClassHelper.make(AutoConfiguration.class));
+ if (autoConfigurationAnnotations.isEmpty()) {
+ addError(pluginClass, source, "A Plugin class using @GrailsBeans
must also be annotated " +
+ "@AutoConfiguration (even with no before=/after=) -
otherwise the generated " +
+ defaultSiblingSimpleName(pluginClass) +
+ " class would never be processed by Spring Boot");
+ }
+
+ String siblingSimpleName = siblingSimpleName(pluginClass,
grailsBeansAnnotation, source);
+ String packageName = pluginClass.getPackageName();
+ String siblingName = (packageName == null || packageName.isEmpty()) ?
+ siblingSimpleName : packageName + "." + siblingSimpleName;
+ ClassNode sibling = new ClassNode(siblingName, Modifier.PUBLIC,
ClassHelper.OBJECT_TYPE);
+ // Without a position, anything Groovy later reports against a
generated node - a sibling
+ // name clash, a typo in .annotate(...) - is reported at line -1,
column -1.
+ sibling.setSourcePosition(pluginClass);
+ source.getAST().addClass(sibling);
+
+ // Matching annotations move entirely rather than being merely copied
- they have no effect
+ // where the author wrote them (see SIBLING_ONLY_ANNOTATION_NAMES).
+ Set<String> moveAnnotationNames =
parseMoveAnnotations(grailsBeansAnnotation, source);
+ List<AnnotationNode> siblingAnnotations = new ArrayList<>();
+ for (AnnotationNode annotation : pluginClass.getAnnotations()) {
+ if (belongsOnSibling(annotation.getClassNode(),
moveAnnotationNames)) {
+ siblingAnnotations.add(annotation);
+ }
+ }
+ sibling.addAnnotations(siblingAnnotations);
+ pluginClass.getAnnotations().removeAll(siblingAnnotations);
+
+ return sibling;
+ }
+
+ private Set<String> parseMoveAnnotations(AnnotationNode
grailsBeansAnnotation, SourceUnit source) {
+ Expression member =
grailsBeansAnnotation.getMember(MOVE_ANNOTATIONS_MEMBER);
+ if (member == null) {
+ return Set.of();
+ }
+ List<Expression> entries = member instanceof ListExpression ?
+ ((ListExpression) member).getExpressions() : List.of(member);
+ Set<String> names = new HashSet<>();
+ for (Expression entry : entries) {
+ if (!(entry instanceof ClassExpression)) {
+ addError(entry, source, "moveAnnotations entries must be
annotation class literals, " +
+ "e.g. @GrailsBeans(moveAnnotations =
[ComponentScan])");
+ continue;
+ }
+ ClassNode annotationType = ((ClassExpression) entry).getType();
+ if (!annotationType.isAnnotationDefinition()) {
+ addError(entry, source, "\"" + annotationType.getName() + "\"
is not an annotation type");
+ continue;
+ }
+ names.add(annotationType.getName());
+ }
+ return names;
+ }
+
+ // A *GrailsPlugin name swaps that suffix for AutoConfiguration
(I18nGrailsPlugin ->
+ // I18nAutoConfiguration - the name the hand-written class it replaces
would have had);
+ // anything else appends AutoConfiguration.
+ private String defaultSiblingSimpleName(ClassNode pluginClass) {
+ String simpleName = pluginClass.getNameWithoutPackage();
+ if (simpleName.endsWith(GRAILS_PLUGIN_SUFFIX) && simpleName.length() >
GRAILS_PLUGIN_SUFFIX.length()) {
+ return simpleName.substring(0, simpleName.length() -
GRAILS_PLUGIN_SUFFIX.length()) + AUTO_CONFIGURATION_SUFFIX;
+ }
+ return simpleName + AUTO_CONFIGURATION_SUFFIX;
+ }
+
+ private String siblingSimpleName(ClassNode pluginClass, AnnotationNode
grailsBeansAnnotation, SourceUnit source) {
+ String defaultName = defaultSiblingSimpleName(pluginClass);
+ Expression nameArg =
grailsBeansAnnotation.getMember(AUTO_CONFIGURATION_NAME_MEMBER);
+ if (nameArg == null) {
+ return defaultName;
+ }
+ Object nameValue = nameArg instanceof ConstantExpression ?
((ConstantExpression) nameArg).getValue() : null;
+ if (!(nameValue instanceof String)) {
+ addError(nameArg, source, "@GrailsBeans(autoConfigurationName =
...) requires a String literal");
+ return defaultName;
+ }
+ String name = (String) nameValue;
+ if (name.isBlank()) {
+ addError(nameArg, source, "@GrailsBeans(autoConfigurationName =
\"" + name + "\") must not be " +
+ "blank - omit the attribute entirely to use the default "
+ defaultName + " instead");
+ return defaultName;
+ }
+ if (!isValidJavaIdentifier(name)) {
+ addError(nameArg, source, "@GrailsBeans(autoConfigurationName =
\"" + name + "\") is not a valid " +
+ "name: it becomes the generated sibling's simple class
name, so it must be a valid Java identifier");
+ return defaultName;
+ }
+ return name;
+ }
+
+ private boolean belongsOnSibling(ClassNode annotationType, Set<String>
moveAnnotationNames) {
+ return belongsOnSibling(annotationType, moveAnnotationNames, new
HashSet<>());
+ }
+
+ // Recurses through meta-annotations rather than checking only one level,
since Spring's own
+ // composed-annotation convention is arbitrarily deep - e.g. a
project-specific
+ // @ConditionalOnFeature is typically meta-annotated with an existing
@ConditionalOnXxx (itself
+ // meta-annotated @Conditional), not with @Conditional directly. `visited`
guards against cycles
+ // and, since every annotation type transitively reaches common JDK
meta-annotations
+ // (@Retention, @Target, @Documented) from multiple paths, avoids
redundant re-exploration.
+ private boolean belongsOnSibling(ClassNode annotationType, Set<String>
moveAnnotationNames, Set<String> visited) {
+ if (!visited.add(annotationType.getName())) {
+ return false;
+ }
+ if (SIBLING_ONLY_ANNOTATION_NAMES.contains(annotationType.getName()) ||
+ moveAnnotationNames.contains(annotationType.getName())) {
+ return true;
+ }
+ for (AnnotationNode metaAnnotation : annotationType.getAnnotations()) {
+ if (belongsOnSibling(metaAnnotation.getClassNode(),
moveAnnotationNames, visited)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ // The bean bodies are lifted onto a class the normal transform pipeline
no longer visits, so
+ // whichever of these the author put on the plugin has to be re-applied
here or the bodies are
+ // silently left unchecked. @GrailsCompileStatic and @GrailsTypeChecked
need no handling of their
+ // own: @AnnotationCollector has already expanded them by canonicalization.
+ private void applyStaticCompilation(ClassNode pluginClass, ClassNode
sibling, SourceUnit source) {
+ if (compilationUnit == null) {
+ return;
+ }
+ if (applyStaticTypesTransformation(pluginClass, sibling, source,
+ CompileStatic.class, new StaticCompileTransformation())) {
+ return;
+ }
+ applyStaticTypesTransformation(pluginClass, sibling, source,
+ TypeChecked.class, new StaticTypesTransformation());
+ }
+
+ private boolean applyStaticTypesTransformation(ClassNode pluginClass,
ClassNode sibling, SourceUnit source,
+ Class<? extends java.lang.annotation.Annotation> annotationType,
StaticTypesTransformation transformation) {
+ List<AnnotationNode> annotations =
pluginClass.getAnnotations(ClassHelper.make(annotationType));
+ if (annotations.isEmpty()) {
+ return false;
+ }
+
+ AnnotationNode sourceAnnotation = annotations.get(0);
+ AnnotationNode siblingAnnotation = new
AnnotationNode(ClassHelper.make(annotationType));
+ sourceAnnotation.getMembers().forEach(siblingAnnotation::setMember);
+ siblingAnnotation.setSourcePosition(sourceAnnotation);
+ sibling.addAnnotation(siblingAnnotation);
+
+ transformation.setCompilationUnit(compilationUnit);
+ transformation.visit(new ASTNode[] { siblingAnnotation, sibling },
source);
+ return true;
+ }
+
+ private List<Statement> beanStatements(ClosureExpression dsl) {
+ Statement code = dsl.getCode();
+ if (code instanceof BlockStatement) {
+ return ((BlockStatement) code).getStatements();
+ }
+ List<Statement> single = new ArrayList<>();
+ single.add(code);
+ return single;
+ }
+
+ // Generated names must not collide with anything the host class already
has: its own fields
+ // and methods (in the standalone form the host is a real user-written
class), every method
+ // inherited through its full type graph - superclasses and interfaces
alike, since a bean
+ // named 'toString' or after an interface's default method must
synthesize, not override -
+ // and the GroovyObject methods Groovy itself adds at class generation.
+ private Set<String> existingMemberNames(ClassNode host) {
+ Set<String> names = new HashSet<>();
+ for (FieldNode field : host.getFields()) {
+ names.add(field.getName());
+ }
+ Set<String> visited = new HashSet<>();
+ collectMethodNames(host, names, visited);
+ collectMethodNames(ClassHelper.GROOVY_OBJECT_TYPE, names, visited);
+ return names;
+ }
+
+ private void collectMethodNames(ClassNode type, Set<String> names,
Set<String> visited) {
+ if (type == null || !visited.add(type.getName())) {
+ return;
+ }
+ for (MethodNode method : type.getMethods()) {
+ names.add(method.getName());
+ }
+ // A Groovy property's accessors are synthesized by the Verifier at
class generation, AFTER
+ // this transform runs, so they are not in getMethods() yet - reserve
the names they will
+ // occupy, or a same-named bean method would displace the real
accessor.
+ for (PropertyNode property : type.getProperties()) {
+ String capitalized = BeanUtils.capitalize(property.getName());
+ names.add("get" + capitalized);
+ names.add("set" + capitalized);
+ if (ClassHelper.boolean_TYPE.equals(property.getType()) ||
+ ClassHelper.Boolean_TYPE.equals(property.getType())) {
+ names.add("is" + capitalized);
+ }
+ }
+ collectMethodNames(type.getSuperClass(), names, visited);
+ for (ClassNode implemented : type.getInterfaces()) {
+ collectMethodNames(implemented, names, visited);
+ }
+ }
+
+ // Silent classification counterpart of processStatement's qualifier-chain
walk: descends to
+ // the root call without reporting anything, so malformed statements are
classified (not
+ // validated) here and still produce their usual errors when actually
processed.
+ private boolean isBeanRootedStatement(Statement statement) {
+ if (!(statement instanceof ExpressionStatement) ||
+ !(((ExpressionStatement) statement).getExpression() instanceof
MethodCallExpression)) {
+ return false;
+ }
+ MethodCallExpression call = (MethodCallExpression)
((ExpressionStatement) statement).getExpression();
+ while (!ROOT_STATEMENT_CALL_NAMES.contains(call.getMethodAsString()) &&
+ call.getObjectExpression() instanceof MethodCallExpression) {
+ call = (MethodCallExpression) call.getObjectExpression();
+ }
+ return BEAN_CALL.equals(call.getMethodAsString());
+ }
+
+ // A Spring bean name may be declared by more than one bean(...) statement
- the standard
+ // autoconfiguration pattern for mutually exclusive variants of one bean,
e.g. Grails' two
+ // "grailsUrlConverter" beans selected by @ConditionalOnProperty - but
only when every
+ // statement sharing the name carries a condition of its own that could
discriminate between
+ // them at runtime. Without one, the duplicates can never all take effect
(Spring keeps the
+ // first definition from a configuration class and silently skips the
rest), so the likeliest
+ // explanation is a copy-paste accident - rejected at compile time
instead. The shared-name
+ // back-off (.conditionalOnMissingBeanName(), or
.conditionalOnMissingBean() with no
+ // arguments) does not count: it is identical on every duplicate by
construction, so it can
+ // never tell them apart.
+ private void validateSharedBeanNames(List<Statement> statements,
SourceUnit source) {
+ Map<String, List<BeanNameUse>> usesByName = new LinkedHashMap<>();
+ for (Statement statement : statements) {
+ if (!isBeanRootedStatement(statement)) {
+ continue;
+ }
+ BeanNameUse use = parseBeanNameUse(
+ (MethodCallExpression) ((ExpressionStatement)
statement).getExpression());
+ if (use != null) {
+ usesByName.computeIfAbsent(use.beanName, key -> new
ArrayList<>()).add(use);
+ }
+ }
+ for (List<BeanNameUse> uses : usesByName.values()) {
+ if (uses.size() < 2) {
+ continue;
+ }
+ for (BeanNameUse use : uses) {
+ if (!use.conditioned) {
+ addError(use.baseCall, source, "\"" + use.beanName + "\"
is already used as the Spring " +
+ "bean name of another bean(...) statement -
declaring it more than once is only " +
+ "allowed when every declaration with the name
carries its own discriminating " +
+ "condition (e.g. .annotate(ConditionalOnProperty,
...)), so that at most one of " +
+ "them registers at runtime");
+ }
+ }
+ }
+ }
+
+ private static final class BeanNameUse {
+ private final String beanName;
+ private final MethodCallExpression baseCall;
+ private final boolean conditioned;
+
+ BeanNameUse(String beanName, MethodCallExpression baseCall, boolean
conditioned) {
+ this.beanName = beanName;
+ this.baseCall = baseCall;
+ this.conditioned = conditioned;
+ }
+ }
+
+ // Silent classification counterpart of processBeanStatement's parsing, in
the same spirit as
+ // isBeanRootedStatement: extracts the bean name and whether the statement
carries a
+ // discriminating condition, returning null for anything malformed - a
malformed statement
+ // still produces its usual errors when actually processed.
+ private BeanNameUse parseBeanNameUse(MethodCallExpression outerCall) {
+ List<MethodCallExpression> qualifierCalls = new ArrayList<>();
+ MethodCallExpression baseCall = outerCall;
+ while
(!ROOT_STATEMENT_CALL_NAMES.contains(baseCall.getMethodAsString())) {
+ if (!(baseCall.getObjectExpression() instanceof
MethodCallExpression)) {
+ return null;
+ }
+ qualifierCalls.add(baseCall);
+ baseCall = (MethodCallExpression) baseCall.getObjectExpression();
+ }
+ if (!BEAN_CALL.equals(baseCall.getMethodAsString())) {
+ return null;
+ }
+
+ List<Expression> args =
withoutTrailingClosure(flatten(baseCall.getArguments()), baseCall, outerCall);
+ if (args.isEmpty() || args.size() > 2 || !(args.get(args.size() - 1)
instanceof ClassExpression)) {
+ return null;
+ }
+ String name;
+ if (args.size() == 1) {
+ name = decapitalize(((ClassExpression)
args.get(0)).getType().getNameWithoutPackage());
+ }
+ else {
+ Object nameValue = args.get(0) instanceof ConstantExpression ?
+ ((ConstantExpression) args.get(0)).getValue() : null;
+ if (!(nameValue instanceof String)) {
+ return null;
+ }
+ name = (String) nameValue;
+ }
+ return new BeanNameUse(name, baseCall,
hasDiscriminatingCondition(qualifierCalls, outerCall));
+ }
+
+ private boolean hasDiscriminatingCondition(List<MethodCallExpression>
qualifierCalls, MethodCallExpression outerCall) {
+ for (MethodCallExpression qualifierCall : qualifierCalls) {
+ String qualifierName = qualifierCall.getMethodAsString();
+ List<Expression> args =
withoutTrailingClosure(flatten(qualifierCall.getArguments()), qualifierCall,
outerCall);
+ if (CONDITIONAL_ON_MISSING_BEAN_CALL.equals(qualifierName) &&
discriminatesByType(args)) {
+ return true;
+ }
+ if (ANNOTATE_CALL.equals(qualifierName)) {
+ for (Expression arg : args) {
+ if (arg instanceof ClassExpression &&
isConditionalAnnotation(((ClassExpression) arg).getType())) {
+ return true;
+ }
+ }
+ }
+ }
+ return false;
+ }
+
+ // @ConditionalOnMissingBean attributes that say nothing about a *type*.
Where duplicates share
+ // one bean name - the only situation validateSharedBeanNames runs in - a
name: or search: is
+ // identical on each of them and so can no more tell them apart than the
bare form can.
+ private static final Set<String>
NON_DISCRIMINATING_MISSING_BEAN_ATTRIBUTES = Set.of("name", "search");
+
+ private boolean discriminatesByType(List<Expression> args) {
+ boolean sawSomething = false;
+ for (Expression arg : args) {
+ if (arg instanceof ClassExpression) {
+ return true;
+ }
+ if (!(arg instanceof MapExpression)) {
+ // an argument shape this method does not model - stay lenient
rather than reject
+ return true;
+ }
+ for (MapEntryExpression entry : ((MapExpression)
arg).getMapEntryExpressions()) {
+ Object key = entry.getKeyExpression() instanceof
ConstantExpression ?
+ ((ConstantExpression)
entry.getKeyExpression()).getValue() : null;
+ if (!(key instanceof String) ||
!NON_DISCRIMINATING_MISSING_BEAN_ATTRIBUTES.contains(key)) {
+ return true;
+ }
+ sawSomething = true;
+ }
+ }
+ return !sawSomething && !args.isEmpty();
+ }
+
+ private List<Expression> withoutTrailingClosure(List<Expression> args,
MethodCallExpression call,
+ MethodCallExpression outerCall) {
+ if (call == outerCall && !args.isEmpty() && args.get(args.size() - 1)
instanceof ClosureExpression) {
+ return args.subList(0, args.size() - 1);
+ }
+ return args;
+ }
+
+ private boolean isConditionalAnnotation(ClassNode annotationType) {
+ return isConditionalAnnotation(annotationType, new HashSet<>());
+ }
+
+ // The same transitive meta-annotation walk belongsOnSibling does, against
@Conditional alone:
+ // every @ConditionalOnXxx - Spring Boot's own and arbitrarily-deeply
composed custom ones -
+ // eventually reaches @Conditional through its meta-annotations.
+ private boolean isConditionalAnnotation(ClassNode annotationType,
Set<String> visited) {
+ if (!visited.add(annotationType.getName())) {
+ return false;
+ }
+ if (Conditional.class.getName().equals(annotationType.getName())) {
+ return true;
+ }
+ for (AnnotationNode metaAnnotation : annotationType.getAnnotations()) {
+ if (isConditionalAnnotation(metaAnnotation.getClassNode(),
visited)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private void processStatement(ClassNode classNode, Statement statement,
SourceUnit source, Set<String> usedNames) {
+ if (!(statement instanceof ExpressionStatement) ||
+ !(((ExpressionStatement) statement).getExpression() instanceof
MethodCallExpression)) {
+ addError(statement, source, "Each 'beans' statement must be a
bean(...), field(...), or method(...) call");
+ return;
+ }
+
+ MethodCallExpression outerCall = (MethodCallExpression)
((ExpressionStatement) statement).getExpression();
+
+ // Walk from the outermost (last-written) call back to the
bean(...)/field(...)/method(...)
+ // call at the root, collecting any chained qualifiers along the way.
+ List<MethodCallExpression> qualifierCalls = new ArrayList<>();
+ MethodCallExpression baseCall = outerCall;
+ while
(!ROOT_STATEMENT_CALL_NAMES.contains(baseCall.getMethodAsString())) {
+ if
(!ALL_QUALIFIER_CALL_NAMES.contains(baseCall.getMethodAsString()) ||
+ !(baseCall.getObjectExpression() instanceof
MethodCallExpression)) {
+ addError(statement, source, "Expected bean([\"name\", ] Type)
{ ... }, field([\"name\", ] Type), " +
+ "or method([\"name\", ] Type) { ... }, optionally
chained with qualifiers");
+ return;
+ }
+ qualifierCalls.add(0, baseCall);
+ baseCall = (MethodCallExpression) baseCall.getObjectExpression();
+ }
+
+ // The walk above stops at the first root call name it meets, so
without this a root
+ // statement chained onto another - field("suffix",
String).bean("greeter", String) { } -
+ // parses as one statement and the left-hand declaration is silently
dropped.
+ if (!baseCall.isImplicitThis() && baseCall.getObjectExpression()
instanceof MethodCallExpression) {
+ addError(statement, source, baseCall.getMethodAsString() + "(...)
cannot be chained onto " +
+ ((MethodCallExpression)
baseCall.getObjectExpression()).getMethodAsString() +
+ "(...) - each bean(...), field(...) and method(...)
declaration is its own statement");
+ return;
+ }
+
+ String rootName = baseCall.getMethodAsString();
+ boolean isBean = BEAN_CALL.equals(rootName);
+ Set<String> allowedQualifiers = isBean ? BEAN_QUALIFIER_CALL_NAMES :
+ FIELD_CALL.equals(rootName) ? FIELD_QUALIFIER_CALL_NAMES :
METHOD_QUALIFIER_CALL_NAMES;
+ for (MethodCallExpression qualifierCall : qualifierCalls) {
+ if
(!allowedQualifiers.contains(qualifierCall.getMethodAsString())) {
+ addError(qualifierCall, source, "." +
qualifierCall.getMethodAsString() + "(...) cannot be " +
+ "chained onto " + rootName + "(...)");
+ return;
+ }
+ }
+
+ // .annotate(...) is repeatable (once per distinct annotation type,
enforced when the
+ // annotation is actually attached below); every other qualifier is
single-use.
+ Set<String> seenQualifiers = new HashSet<>();
+ for (MethodCallExpression qualifierCall : qualifierCalls) {
+ String qualifierName = qualifierCall.getMethodAsString();
+ if (!ANNOTATE_CALL.equals(qualifierName) &&
!seenQualifiers.add(qualifierName)) {
+ addError(qualifierCall, source, "." + qualifierName + "(...)
may only be chained once per " +
+ rootName + "(...)");
+ return;
+ }
+ }
+
+ if (isBean) {
+ processBeanStatement(classNode, outerCall, baseCall,
qualifierCalls, source, usedNames);
+ }
+ else if (FIELD_CALL.equals(rootName)) {
+ processFieldStatement(classNode, baseCall, qualifierCalls, source,
usedNames);
+ }
+ else {
+ processMethodStatement(classNode, outerCall, baseCall,
qualifierCalls, source, usedNames);
+ }
+ }
+
+ private boolean registerName(String name, ASTNode location, SourceUnit
source, Set<String> usedNames, String errorSuffix) {
+ if (!usedNames.add(name)) {
+ addError(location, source, "\"" + name + "\" " + errorSuffix);
+ return false;
+ }
+ return true;
+ }
+
+ private void processBeanStatement(ClassNode classNode,
MethodCallExpression outerCall, MethodCallExpression baseCall,
+ List<MethodCallExpression> qualifierCalls, SourceUnit source,
Set<String> usedNames) {
+ // The factory closure is optional: bean(Type) with no body declares a
bean that is just its
+ // own no-argument construction, which is by far the most common shape
and reads as noise
+ // when spelled out as bean(Type) { new Type() }.
+ List<Expression> closureCallArgs = flatten(outerCall.getArguments());
+ ClosureExpression factory = !closureCallArgs.isEmpty() &&
+ closureCallArgs.get(closureCallArgs.size() - 1) instanceof
ClosureExpression ?
+ (ClosureExpression) closureCallArgs.get(closureCallArgs.size()
- 1) : null;
+
+ // When bean(...) is itself the outermost call (no qualifiers
chained), it carries the
+ // trailing closure as its own last argument - exclude it before
validating the [name, ] Type
+ // shape, since it was already validated above.
+ List<Expression> baseArgs = flatten(baseCall.getArguments());
+ if (factory != null && baseCall == outerCall && !baseArgs.isEmpty()) {
+ baseArgs = baseArgs.subList(0, baseArgs.size() - 1);
+ }
+
+ TypeAndName typeAndName = parseNameAndType(baseArgs, baseCall, source,
BEAN_CALL, false);
+ if (typeAndName == null) {
+ return;
+ }
+
+ ClassNode beanType = typeAndName.type.getType();
+ // A closure whose body is empty declares construction too, from its
own parameters: the
+ // parameters say what is injected, and the generated body is the
constructor call the author
+ // would otherwise have written out. bean(Type) { } with no parameters
is bean(Type).
+ boolean constructsDeclaredType = factory == null ||
isEmpty(factory.getCode());
+ if (constructsDeclaredType && (beanType.isInterface() ||
Modifier.isAbstract(beanType.getModifiers()))) {
+ addError(baseCall, source, "bean(" +
beanType.getNameWithoutPackage() + ") with no factory closure body " +
+ "constructs the declared type, which cannot be done for an
interface or abstract class - " +
+ "give it a body: bean(" + beanType.getNameWithoutPackage()
+ ") { new SomeImplementation() }");
+ return;
+ }
+
+ // The method name is an implementation detail - Spring resolves the
bean by its
+ // @Bean("name") value, never by the factory method's name - so a bean
name that isn't a
+ // usable Java identifier, or is already taken by an existing member,
synthesizes instead
+ // of erroring.
+ String javaMethodName = isValidJavaIdentifier(typeAndName.name) &&
!usedNames.contains(typeAndName.name) ?
+ typeAndName.name :
+ syntheticBeanMethodName(typeAndName.type.getType(), usedNames);
+ usedNames.add(javaMethodName);
+
+ Parameter[] beanParameters = factory == null ||
factory.getParameters() == null ?
+ Parameter.EMPTY_ARRAY : factory.getParameters();
+ Statement beanBody = constructsDeclaredType ?
+ new ReturnStatement(new ConstructorCallExpression(beanType,
constructorArguments(beanParameters))) :
+ factory.getCode();
+
+ MethodNode beanMethod = new MethodNode(
+ javaMethodName,
+ Modifier.PUBLIC,
+ beanType,
+ beanParameters,
+ ClassNode.EMPTY_ARRAY,
+ beanBody);
+ beanMethod.setSourcePosition(baseCall);
+
beanMethod.addAnnotation(withPosition(beanAnnotation(typeAndName.name),
baseCall));
+
+ for (MethodCallExpression qualifierCall : qualifierCalls) {
+ List<Expression> qualifierArgs =
flatten(qualifierCall.getArguments());
+ if (factory != null && qualifierCall == outerCall) {
+ // only the outermost call in the chain can carry the trailing
factory closure
+ qualifierArgs = qualifierArgs.subList(0, qualifierArgs.size()
- 1);
+ }
+ if (!applyQualifier(beanMethod, typeAndName.name, qualifierCall,
qualifierArgs, source)) {
+ return;
+ }
+ }
+
+ classNode.addMethod(beanMethod);
+ }
+
+ private boolean isEmpty(Statement code) {
+ return code == null || code instanceof EmptyStatement ||
+ (code instanceof BlockStatement && ((BlockStatement)
code).getStatements().isEmpty());
+ }
+
+ /**
+ * The closure's parameters, passed straight through as the constructor's
arguments. Which
+ * constructor that selects is left to the compiler, resolved from these
argument types exactly as
+ * it would be for a hand-written {@code new Type(...)} - nothing here
reads the declared type's
+ * constructors, so adding one cannot change what this bean injects.
+ */
+ private ArgumentListExpression constructorArguments(Parameter[]
parameters) {
+ ArgumentListExpression arguments = new ArgumentListExpression();
+ for (Parameter parameter : parameters) {
+ arguments.addExpression(new VariableExpression(parameter));
+ }
+ return arguments;
+ }
+
+ private String syntheticBeanMethodName(ClassNode beanType, Set<String>
usedNames) {
+ String base = decapitalize(beanType.getNameWithoutPackage());
+ String candidate;
+ int index = 0;
+ do {
+ candidate = base + "$" + index;
+ index++;
+ }
+ while (usedNames.contains(candidate));
+ return candidate;
+ }
+
+ private void processFieldStatement(ClassNode classNode,
MethodCallExpression baseCall,
+ List<MethodCallExpression> qualifierCalls, SourceUnit source,
Set<String> usedNames) {
+ List<Expression> baseArgs = flatten(baseCall.getArguments());
+ TypeAndName typeAndName = parseNameAndType(baseArgs, baseCall, source,
FIELD_CALL, true);
+ if (typeAndName == null) {
+ return;
+ }
+ if (!registerName(typeAndName.name, baseCall, source, usedNames,
+ "is already used by another member of the class (declared,
inherited, or another field(...)/method(...) statement) - " +
+ "generated member names must be unique")) {
+ return;
+ }
+
+ FieldNode field = classNode.addField(typeAndName.name,
Modifier.PRIVATE, typeAndName.type.getType(), null);
+ field.setSourcePosition(baseCall);
+
+ for (MethodCallExpression qualifierCall : qualifierCalls) {
+ List<Expression> qualifierArgs =
flatten(qualifierCall.getArguments());
+ if (VALUE_CALL.equals(qualifierCall.getMethodAsString())) {
+ AnnotationNode valueAnnotation =
valueAnnotation(qualifierArgs, qualifierCall, source);
+ if (valueAnnotation == null || !addAnnotationIfAbsent(field,
qualifierCall, valueAnnotation, source)) {
+ return;
+ }
+ }
+ else if (!applyGenericAnnotation(field, qualifierCall,
qualifierArgs, source)) {
+ return;
+ }
+ }
+ }
+
+ // .value(key, default) builds the '${key:default}' placeholder itself, as
a concatenation the
+ // compiler folds into a constant - which is what lets the key be a bare
static-final constant
+ // reference, the one shape a directly-written annotation value rejects.
.value(single) is a
+ // bare config key with no default, auto-wrapped into '${key}' - injecting
the key's literal
+ // text is never what .value(...) is for - unless the string already
contains a '${'
+ // placeholder or '#{' SpEL expression, which passes through verbatim
(including mixed
+ // literals like 'http://${app.host}/'). A genuine literal stays
expressible via
+ // .annotate(Value, value: ...).
+ private AnnotationNode valueAnnotation(List<Expression> args,
MethodCallExpression qualifierCall, SourceUnit source) {
+ if (args.isEmpty() || args.size() > 2) {
+ addError(qualifierCall, source, ".value(...) requires a config key
and default - e.g. " +
+ ".value(Settings.GSP_VIEW_ENCODING, \"UTF-8\") - or a
single config key/placeholder/SpEL string");
+ return null;
+ }
+ // The pieces are resolved and folded HERE, into a plain constant,
rather than being left
+ // as a concatenation for Groovy's own annotation folding: under
@CompileStatic the static
+ // compiler rewrites '+' into .plus() calls before that folding runs,
which would reject
+ // the member as a non-constant.
+ String memberValue;
+ if (args.size() == 1) {
+ String placeholder = resolveStringConstant(args.get(0));
+ if (placeholder == null) {
+ addError(args.get(0), source, ".value(...) arguments must be
compile-time String constants " +
+ "(a literal, a static final constant reference, or a
concatenation of those)");
+ return null;
+ }
+ if (placeholder.isBlank()) {
+ addError(args.get(0), source, ".value(...) requires a
non-blank config key - a blank one " +
+ "would compile to the unresolvable placeholder ${}");
+ return null;
+ }
+ memberValue = placeholder.contains("${") ||
placeholder.contains("#{") ?
+ placeholder : "${" + placeholder + "}";
+ }
+ else {
+ String key = resolveStringConstant(args.get(0));
+ String defaultValue = resolveStringConstant(args.get(1));
+ if (key == null || defaultValue == null) {
+ addError(key == null ? args.get(0) : args.get(1), source,
".value(...) arguments must be " +
+ "compile-time String constants (a literal, a static
final constant reference, or a " +
+ "concatenation of those)");
+ return null;
+ }
+ // Only the KEY must be non-blank: a deliberately blank default
('${key:}') is legal
+ // and used (e.g. grails.i18n.default.locale falls back to the JVM
default locale).
+ if (key.isBlank()) {
+ addError(args.get(0), source, ".value(key, default) requires a
non-blank config key - " +
+ "only the default may be blank");
+ return null;
+ }
+ memberValue = "${" + key + ":" + defaultValue + "}";
+ }
+ AnnotationNode annotation = new
AnnotationNode(ClassHelper.make(Value.class));
+ annotation.setMember("value", new ConstantExpression(memberValue));
+ return annotation;
+ }
+
+ // Resolves an expression to its compile-time String value: literals
directly; a static final
+ // constant reference either from its AST initial expression (a constant
declared in the same
+ // compilation unit) or reflectively from the already-compiled class on
the classpath; and
+ // concatenations of resolvable pieces recursively.
+ private String resolveStringConstant(Expression expression) {
+ if (expression instanceof ConstantExpression) {
+ Object value = ((ConstantExpression) expression).getValue();
+ return value instanceof String ? (String) value : null;
+ }
+ if (expression instanceof BinaryExpression) {
+ BinaryExpression binary = (BinaryExpression) expression;
+ if (binary.getOperation().getType() != Types.PLUS) {
+ return null;
+ }
+ String left = resolveStringConstant(binary.getLeftExpression());
+ String right = resolveStringConstant(binary.getRightExpression());
+ return left != null && right != null ? left + right : null;
+ }
+ if (expression instanceof PropertyExpression) {
+ PropertyExpression property = (PropertyExpression) expression;
+ if (!(property.getObjectExpression() instanceof ClassExpression)) {
+ return null;
+ }
+ ClassNode owner = property.getObjectExpression().getType();
+ String fieldName = property.getPropertyAsString();
+ if (fieldName == null) {
+ return null;
+ }
+ FieldNode field = findStaticFinalField(owner, fieldName, new
HashSet<>());
+ if (field != null && field.getInitialExpression() instanceof
ConstantExpression) {
+ Object value = ((ConstantExpression)
field.getInitialExpression()).getValue();
+ return value instanceof String ? (String) value : null;
+ }
+ // The reflective fallback needs a loaded Class, which a ClassNode
from this same
+ // compilation unit does not have - getTypeClass() would throw
GroovyBugError, an
+ // AssertionError that no catch below would hold, aborting the
compilation with an
+ // internal compiler error instead of the located message the
caller reports.
+ try {
+ Object value =
owner.getTypeClass().getField(fieldName).get(null);
+ return value instanceof String ? (String) value : null;
+ }
+ catch (ReflectiveOperationException | RuntimeException |
LinkageError | GroovyBugError ignored) {
+ return null;
+ }
+ }
+ return null;
+ }
+
+ // ClassNode.getField walks superclasses but not interfaces, so a constant
declared on an
+ // implemented interface - the shape every grails.config.Settings key has
- is invisible to it
+ // while the owner is still being compiled.
+ private FieldNode findStaticFinalField(ClassNode owner, String fieldName,
Set<String> visited) {
+ for (ClassNode type = owner; type != null; type =
type.getSuperClass()) {
+ if (!visited.add(type.getName())) {
+ return null;
+ }
+ FieldNode declared = type.getDeclaredField(fieldName);
+ if (declared != null && declared.isStatic() && declared.isFinal())
{
+ return declared;
+ }
+ for (ClassNode implemented : type.getInterfaces()) {
+ FieldNode inherited = findStaticFinalField(implemented,
fieldName, visited);
+ if (inherited != null) {
+ return inherited;
+ }
+ }
+ }
+ return null;
+ }
+
+ private void processMethodStatement(ClassNode classNode,
MethodCallExpression outerCall, MethodCallExpression baseCall,
+ List<MethodCallExpression> qualifierCalls, SourceUnit source,
Set<String> usedNames) {
+ List<Expression> closureCallArgs = flatten(outerCall.getArguments());
+ if (closureCallArgs.isEmpty() ||
!(closureCallArgs.get(closureCallArgs.size() - 1) instanceof
ClosureExpression)) {
+ addError(outerCall, source, "method(...) must end with a body
closure: method(\"name\", Type) { ... }");
+ return;
+ }
+ ClosureExpression body = (ClosureExpression)
closureCallArgs.get(closureCallArgs.size() - 1);
+
+ List<Expression> baseArgs = flatten(baseCall.getArguments());
+ if (baseCall == outerCall && !baseArgs.isEmpty()) {
+ baseArgs = baseArgs.subList(0, baseArgs.size() - 1);
+ }
+
+ TypeAndName typeAndName = parseNameAndType(baseArgs, baseCall, source,
METHOD_CALL, true);
+ if (typeAndName == null) {
+ return;
+ }
+ if (!registerName(typeAndName.name, baseCall, source, usedNames,
+ "is already used by another member of the class (declared,
inherited, or another field(...)/method(...) statement) - " +
+ "generated member names must be unique")) {
+ return;
+ }
+
+ MethodNode helperMethod = new MethodNode(
+ typeAndName.name,
+ Modifier.PRIVATE,
+ typeAndName.type.getType(),
+ body.getParameters() == null ? Parameter.EMPTY_ARRAY :
body.getParameters(),
+ ClassNode.EMPTY_ARRAY,
+ body.getCode());
+ helperMethod.setSourcePosition(baseCall);
+
+ for (MethodCallExpression qualifierCall : qualifierCalls) {
+ List<Expression> qualifierArgs =
flatten(qualifierCall.getArguments());
+ if (qualifierCall == outerCall) {
+ qualifierArgs = qualifierArgs.subList(0, qualifierArgs.size()
- 1);
+ }
+ if (!applyGenericAnnotation(helperMethod, qualifierCall,
qualifierArgs, source)) {
+ return;
+ }
+ }
+
+ classNode.addMethod(helperMethod);
+ }
+
+ private static final class TypeAndName {
+ private final ClassExpression type;
+ private final String name;
+
+ TypeAndName(ClassExpression type, String name) {
+ this.type = type;
+ this.name = name;
+ }
+ }
+
+ private TypeAndName parseNameAndType(List<Expression> args,
MethodCallExpression call, SourceUnit source,
+ String callName, boolean requireValidIdentifier) {
+ if (args.isEmpty() || args.size() > 2 || !(args.get(args.size() - 1)
instanceof ClassExpression)) {
+ if (args.size() == 2 && args.get(0) instanceof ClassExpression) {
+ addError(call, source, callName + "(...) takes the name before
the type: " +
Review Comment:
This branch fires for a shape that has nothing to do with argument order,
and the message then sends the reader somewhere unrelated. Chaining a qualifier
*after* the factory closure rather than before it:
```groovy
bean(String) { 'hi' }.lazy()
```
```
bean(...) takes the name before the type: bean("myGreeter", Greeter), not
bean(Greeter, "myGreeter")
@ line 9, column 34.
bean(String) { 'hi' }.lazy()
^
```
The named spelling avoids the wrong advice but is no more useful:
```groovy
bean('greeting', String) { 'hi' }.lazy()
```
```
bean(...) requires a type, optionally preceded by a name, e.g. bean(Greeter)
or bean("myGreeter", Greeter)
```
It arrives here because `factory` is read off `outerCall`'s arguments, so a
closure sitting on `baseCall` is never stripped and is still in `baseArgs` at
this point. Both spellings are a plausible first guess: the closure reads as
belonging to `bean(...)`, and it is where a body goes in every other DSL a
Grails author writes. `method('name', Type) { ... }.annotate(X)` lands the same
way, via "method(...) must end with a body closure".
Detecting a trailing `ClosureExpression` on the base call when qualifiers
are present, and saying that qualifiers come before the body, is a one-line
diagnostic — and a row in the malformed-statement table.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]