This is an automated email from the ASF dual-hosted git repository. jdaugherty pushed a commit to branch feature/compileStaticTagLibs in repository https://gitbox.apache.org/repos/asf/grails-core.git
commit a15d96736779584a3da11707d66668fb75eea87f Author: James Daugherty <[email protected]> AuthorDate: Mon May 18 01:07:29 2026 -0400 Add `@GrailsCompileStatic` support for controllers that call tag libs --- .../grails/compiler/GrailsCompileStatic.groovy | 17 ++-- grails-doc/src/en/guide/introduction/whatsNew.adoc | 25 ++++++ .../grailsCompileStatic.adoc | 30 ++++++- .../ControllerTagLibTypeCheckingExtension.groovy | 93 ++++++++++++++++++++++ .../ControllerCompileStaticTagLibSpec.groovy | 80 +++++++++++++++++++ .../demo/CompileStaticController.groovy | 33 ++++---- .../groovy/demo/CompileStaticControllerSpec.groovy | 45 +++++++++++ .../groovy/demo/CompileStaticControllerSpec.groovy | 42 ++++++---- 8 files changed, 325 insertions(+), 40 deletions(-) diff --git a/grails-core/src/main/groovy/grails/compiler/GrailsCompileStatic.groovy b/grails-core/src/main/groovy/grails/compiler/GrailsCompileStatic.groovy index 50d0804907..794ea79590 100644 --- a/grails-core/src/main/groovy/grails/compiler/GrailsCompileStatic.groovy +++ b/grails-core/src/main/groovy/grails/compiler/GrailsCompileStatic.groovy @@ -27,11 +27,14 @@ import groovy.transform.CompileStatic * */ @AnnotationCollector -@CompileStatic(extensions=['org.grails.compiler.ValidateableTypeCheckingExtension', - 'org.grails.compiler.NamedQueryTypeCheckingExtension', - 'org.grails.compiler.HttpServletRequestTypeCheckingExtension', - 'org.grails.compiler.WhereQueryTypeCheckingExtension', - 'org.grails.compiler.DynamicFinderTypeCheckingExtension', - 'org.grails.compiler.DomainMappingTypeCheckingExtension', - 'org.grails.compiler.RelationshipManagementMethodTypeCheckingExtension']) +@CompileStatic(extensions = [ + 'org.grails.compiler.ControllerTagLibTypeCheckingExtension', + 'org.grails.compiler.DomainMappingTypeCheckingExtension', + 'org.grails.compiler.DynamicFinderTypeCheckingExtension', + 'org.grails.compiler.HttpServletRequestTypeCheckingExtension', + 'org.grails.compiler.NamedQueryTypeCheckingExtension', + 'org.grails.compiler.RelationshipManagementMethodTypeCheckingExtension', + 'org.grails.compiler.ValidateableTypeCheckingExtension', + 'org.grails.compiler.WhereQueryTypeCheckingExtension', +]) @interface GrailsCompileStatic {} diff --git a/grails-doc/src/en/guide/introduction/whatsNew.adoc b/grails-doc/src/en/guide/introduction/whatsNew.adoc index 17f4bb42d3..82c35f6514 100644 --- a/grails-doc/src/en/guide/introduction/whatsNew.adoc +++ b/grails-doc/src/en/guide/introduction/whatsNew.adoc @@ -36,3 +36,28 @@ The Grails Gradle extension now defaults `preserveParameterNames` to `true`, so Tag library unit tests also clean up and rebuild TagLib metadata automatically between features. Tests that use `TagLibUnitTest` no longer need to manage `purgeTagLibMetaClass`, and specs that mock additional tag libraries continue to work across feature methods. + +==== @GrailsCompileStatic on Controllers That Use Tag Libraries + +Controllers annotated with `@GrailsCompileStatic` can now invoke tag library methods without compile-time errors. + +Both calling patterns are supported out of the box: + +[source,groovy] +---- +import grails.compiler.GrailsCompileStatic + +@GrailsCompileStatic +class BookController { + + def index() { + // Direct call in the default namespace + response.writer << link(controller: 'book', action: 'list') + + // Namespaced call via a dispatcher property + response.writer << my.customTag(attr: 'value') + } +} +---- + +The new `ControllerTagLibTypeCheckingExtension` (bundled with `@GrailsCompileStatic`) recognises controller classes by convention and marks tag dispatch points as permissible dynamic calls, while leaving the rest of the controller fully type-checked. diff --git a/grails-doc/src/en/guide/staticTypeCheckingAndCompilation/grailsCompileStatic.adoc b/grails-doc/src/en/guide/staticTypeCheckingAndCompilation/grailsCompileStatic.adoc index 8a7cea93b6..795f6ed531 100644 --- a/grails-doc/src/en/guide/staticTypeCheckingAndCompilation/grailsCompileStatic.adoc +++ b/grails-doc/src/en/guide/staticTypeCheckingAndCompilation/grailsCompileStatic.adoc @@ -103,4 +103,32 @@ class SomeClass { Code that is marked with `GrailsCompileStatic` will all be statically compiled except for Grails specific interactions that cannot be statically compiled but that `GrailsCompileStatic` can identify as permissible for dynamic dispatch. These include things like invoking dynamic finders and DSL code in configuration blocks like constraints and mapping closures in domain classes. -Care must be taken when deciding to statically compile code. There are benefits associated with static compilation but in order to take advantage of those benefits you are giving up the power and flexibility of dynamic dispatch. For example if code is statically compiled it cannot take advantage of runtime metaprogramming enhancements which may be provided by plugins. \ No newline at end of file +Care must be taken when deciding to statically compile code. There are benefits associated with static compilation but in order to take advantage of those benefits you are giving up the power and flexibility of dynamic dispatch. For example if code is statically compiled it cannot take advantage of runtime metaprogramming enhancements which may be provided by plugins. + +===== Tag Library Calls in Controllers + +Controllers annotated with `@GrailsCompileStatic` can invoke tag library methods without compile errors. +Tag dispatch is handled at runtime through `TagLibraryInvoker`, and `@GrailsCompileStatic` includes a built-in type-checking extension that recognises these call sites and allows them to compile. + +Both calling patterns work: + +[source,groovy] +---- +import grails.compiler.GrailsCompileStatic + +@GrailsCompileStatic +class BookController { + + def index() { + // Direct call — tag in the default namespace invoked on `this` + response.writer << link(controller: 'book', action: 'list') + + // Namespaced call — namespace dispatcher property, then tag method + response.writer << g.message(code: 'book.list.title') + } +} +---- + +Only controller classes (those whose name ends with `Controller`) receive this treatment. +All other code in the controller remains fully type-checked. +If you need to opt a single method out of static compilation, use `@GrailsCompileStatic(TypeCheckingMode.SKIP)` on that method. \ No newline at end of file diff --git a/grails-gsp/plugin/src/main/groovy/org/grails/compiler/ControllerTagLibTypeCheckingExtension.groovy b/grails-gsp/plugin/src/main/groovy/org/grails/compiler/ControllerTagLibTypeCheckingExtension.groovy new file mode 100644 index 0000000000..acf79b0923 --- /dev/null +++ b/grails-gsp/plugin/src/main/groovy/org/grails/compiler/ControllerTagLibTypeCheckingExtension.groovy @@ -0,0 +1,93 @@ +/* + * 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 + +import org.codehaus.groovy.ast.ClassNode +import org.codehaus.groovy.ast.expr.PropertyExpression +import org.codehaus.groovy.ast.expr.VariableExpression +import org.codehaus.groovy.transform.stc.GroovyTypeCheckingExtensionSupport +import org.grails.core.artefact.ControllerArtefactHandler + +/** + * A type-checking extension that allows {@code @GrailsCompileStatic} controllers + * to invoke tag library methods without compile-time errors. + * + * <p>Tag calls in controllers are dispatched at runtime through + * {@link grails.artefact.gsp.TagLibraryInvoker#methodMissing} and + * {@link grails.artefact.gsp.TagLibraryInvoker#propertyMissing}. These hooks are + * invisible to the static type checker, so this extension marks the affected + * expressions as dynamic, silencing the false-positive errors while preserving + * full type checking for all other code in the controller. + * + * <p>Controller detection mirrors {@code ControllerActionTransformer}: a class is + * treated as a controller when its qualified name ends with {@code "Controller"}. + * + * <p>Two calling patterns are supported: + * <ul> + * <li>Direct calls on {@code this}: {@code link(controller: 'home')}, + * {@code message(code: 'key')}</li> + * <li>Namespaced calls via a namespace dispatcher property: + * {@code g.message(code: 'key')}, {@code my.customTag(attr: 'val')}</li> + * </ul> + * + * @since 7.0 + */ +class ControllerTagLibTypeCheckingExtension extends GroovyTypeCheckingExtensionSupport.TypeCheckingDSL { + + @Override + Object run() { + beforeVisitClass { ClassNode classNode -> + newScope { + isController = classNode.name.endsWith(ControllerArtefactHandler.TYPE) + dynamicNamespaceProperties = [] as Set + } + } + + afterVisitClass { ClassNode classNode -> + scopeExit() + } + + unresolvedVariable { VariableExpression ve -> + if (currentScope.isController) { + currentScope.dynamicNamespaceProperties << ve + return makeDynamic(ve) + } + null + } + + unresolvedProperty { PropertyExpression pe -> + if (currentScope.isController && isThisReceiver(pe)) { + currentScope.dynamicNamespaceProperties << pe + return makeDynamic(pe) + } + null + } + + methodNotFound { receiver, name, argList, argTypes, call -> + if (!currentScope.isController) return null + if (isThisReceiver(call)) return makeDynamic(call) + if (call.objectExpression in currentScope.dynamicNamespaceProperties) return makeDynamic(call) + null + } + } + + private boolean isThisReceiver(expr) { + expr.implicitThis || (expr.objectExpression instanceof VariableExpression && expr.objectExpression.thisExpression) + } +} diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/ControllerCompileStaticTagLibSpec.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/ControllerCompileStaticTagLibSpec.groovy new file mode 100644 index 0000000000..0818a4f9d0 --- /dev/null +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/ControllerCompileStaticTagLibSpec.groovy @@ -0,0 +1,80 @@ +/* + * 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.web.taglib + +import grails.artefact.Artefact +import grails.compiler.GrailsCompileStatic +import grails.testing.web.controllers.ControllerUnitTest +import spock.lang.Specification + +class ControllerCompileStaticTagLibSpec extends Specification implements ControllerUnitTest<CompileStaticTagController> { + + void setup() { + mockTagLibs(CompileStaticDefaultTagLib, CompileStaticNamespacedTagLib) + } + + void "controller with @GrailsCompileStatic can call a default-namespace tag directly"() { + when: + controller.useDefaultNamespaceTag() + + then: + response.contentAsString == 'hello! World' + } + + void "controller with @GrailsCompileStatic can call a tag via namespace dispatcher property"() { + when: + controller.useNamespacedTag() + + then: + response.contentAsString == 'hello! World' + } +} + +@Artefact('Controller') +@GrailsCompileStatic +class CompileStaticTagController { + + def useDefaultNamespaceTag() { + // tag in default namespace invoked directly on this; dispatched at runtime + // through TagLibraryInvoker.methodMissing + response.writer << greet(name: 'World') + } + + def useNamespacedTag() { + // namespace dispatcher property resolved at runtime through + // TagLibraryInvoker.propertyMissing, tag invoked on the resulting dispatcher + response.writer << cst.greet(name: 'World') + } +} + +@Artefact('TagLib') +class CompileStaticDefaultTagLib { + Closure greet = { attrs, body -> + out << "hello! ${attrs.name}" + } +} + +@Artefact('TagLib') +class CompileStaticNamespacedTagLib { + static namespace = 'cst' + + Closure greet = { attrs, body -> + out << "hello! ${attrs.name}" + } +} diff --git a/grails-core/src/main/groovy/grails/compiler/GrailsCompileStatic.groovy b/grails-test-examples/demo33/grails-app/controllers/demo/CompileStaticController.groovy similarity index 51% copy from grails-core/src/main/groovy/grails/compiler/GrailsCompileStatic.groovy copy to grails-test-examples/demo33/grails-app/controllers/demo/CompileStaticController.groovy index 50d0804907..59ebad7f99 100644 --- a/grails-core/src/main/groovy/grails/compiler/GrailsCompileStatic.groovy +++ b/grails-test-examples/demo33/grails-app/controllers/demo/CompileStaticController.groovy @@ -16,22 +16,25 @@ * specific language governing permissions and limitations * under the License. */ -package grails.compiler +package demo -import groovy.transform.AnnotationCollector -import groovy.transform.CompileStatic +import grails.compiler.GrailsCompileStatic /** - * - * @since 2.4 - * + * Demonstrates that a controller annotated with {@code @GrailsCompileStatic} can + * invoke tag library methods — both in the default namespace (direct call) and via + * a namespace dispatcher property — without compile errors. */ -@AnnotationCollector -@CompileStatic(extensions=['org.grails.compiler.ValidateableTypeCheckingExtension', - 'org.grails.compiler.NamedQueryTypeCheckingExtension', - 'org.grails.compiler.HttpServletRequestTypeCheckingExtension', - 'org.grails.compiler.WhereQueryTypeCheckingExtension', - 'org.grails.compiler.DynamicFinderTypeCheckingExtension', - 'org.grails.compiler.DomainMappingTypeCheckingExtension', - 'org.grails.compiler.RelationshipManagementMethodTypeCheckingExtension']) -@interface GrailsCompileStatic {} +@GrailsCompileStatic +class CompileStaticController { + + def invokeDefaultNamespaceTag() { + // link() is a core tag in the default 'g' namespace; invoked directly on this + response.writer << link(controller: 'demo', action: 'clearDatabase') + } + + def invokeNamespacedTag() { + // one.sayHello() accesses the 'one' namespace dispatcher, then invokes the tag + response.writer << one.sayHello() + } +} diff --git a/grails-test-examples/demo33/src/integration-test/groovy/demo/CompileStaticControllerSpec.groovy b/grails-test-examples/demo33/src/integration-test/groovy/demo/CompileStaticControllerSpec.groovy new file mode 100644 index 0000000000..7bf07b2f11 --- /dev/null +++ b/grails-test-examples/demo33/src/integration-test/groovy/demo/CompileStaticControllerSpec.groovy @@ -0,0 +1,45 @@ +/* + * 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 demo + +import grails.testing.mixin.integration.Integration +import org.apache.grails.testing.http.client.HttpClientSupport +import spock.lang.Specification +import spock.lang.Tag + +@Integration +@Tag('http-client') +class CompileStaticControllerSpec extends Specification implements HttpClientSupport { + + void 'controller with @GrailsCompileStatic can call a default-namespace tag directly'() { + when: + def response = http('/compileStatic/invokeDefaultNamespaceTag') + + then: + response.assertContains('<a href="/demo/clearDatabase"></a>') + } + + void 'controller with @GrailsCompileStatic can call a tag via namespace dispatcher property'() { + when: + def response = http('/compileStatic/invokeNamespacedTag') + + then: + response.assertEquals('BEFORE Hello From SecondTagLib AFTER') + } +} diff --git a/grails-core/src/main/groovy/grails/compiler/GrailsCompileStatic.groovy b/grails-test-examples/demo33/src/test/groovy/demo/CompileStaticControllerSpec.groovy similarity index 50% copy from grails-core/src/main/groovy/grails/compiler/GrailsCompileStatic.groovy copy to grails-test-examples/demo33/src/test/groovy/demo/CompileStaticControllerSpec.groovy index 50d0804907..553ca1d89b 100644 --- a/grails-core/src/main/groovy/grails/compiler/GrailsCompileStatic.groovy +++ b/grails-test-examples/demo33/src/test/groovy/demo/CompileStaticControllerSpec.groovy @@ -16,22 +16,30 @@ * specific language governing permissions and limitations * under the License. */ -package grails.compiler +package demo -import groovy.transform.AnnotationCollector -import groovy.transform.CompileStatic +import grails.testing.web.controllers.ControllerUnitTest +import spock.lang.Specification -/** - * - * @since 2.4 - * - */ -@AnnotationCollector -@CompileStatic(extensions=['org.grails.compiler.ValidateableTypeCheckingExtension', - 'org.grails.compiler.NamedQueryTypeCheckingExtension', - 'org.grails.compiler.HttpServletRequestTypeCheckingExtension', - 'org.grails.compiler.WhereQueryTypeCheckingExtension', - 'org.grails.compiler.DynamicFinderTypeCheckingExtension', - 'org.grails.compiler.DomainMappingTypeCheckingExtension', - 'org.grails.compiler.RelationshipManagementMethodTypeCheckingExtension']) -@interface GrailsCompileStatic {} +class CompileStaticControllerSpec extends Specification implements ControllerUnitTest<CompileStaticController> { + + void setup() { + mockTagLibs FirstTagLib, SecondTagLib + } + + void 'controller with @GrailsCompileStatic can call a default-namespace tag directly'() { + when: + controller.invokeDefaultNamespaceTag() + + then: + response.text == '<a href="/demo/clearDatabase"></a>' + } + + void 'controller with @GrailsCompileStatic can call a tag via namespace dispatcher property'() { + when: + controller.invokeNamespacedTag() + + then: + response.text == 'BEFORE Hello From SecondTagLib AFTER' + } +}
