diff --git a/grails-core/src/main/groovy/grails/compiler/GrailsCompileStatic.groovy b/grails-core/src/main/groovy/grails/compiler/GrailsCompileStatic.groovy index 50d08049078..794ea795903 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-core/src/main/groovy/org/grails/compiler/ControllerTagLibTypeCheckingExtension.groovy b/grails-core/src/main/groovy/org/grails/compiler/ControllerTagLibTypeCheckingExtension.groovy new file mode 100644 index 00000000000..67578b0155d --- /dev/null +++ b/grails-core/src/main/groovy/org/grails/compiler/ControllerTagLibTypeCheckingExtension.groovy @@ -0,0 +1,95 @@ +/* + * 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.MethodCallExpression +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. + * + *

Tag calls in controllers are dispatched at runtime through + * {@code TagLibraryInvoker#methodMissing} and + * {@code 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. + * + *

Controller detection mirrors {@code ControllerActionTransformer}: a class is + * treated as a controller when its qualified name ends with {@code "Controller"}. + * + *

Two calling patterns are supported: + *

+ * + * @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 instanceof MethodCallExpression && call.objectExpression in currentScope.dynamicNamespaceProperties) return makeDynamic(call) + null + } + } + + private boolean isThisReceiver(expr) { + if (!(expr instanceof MethodCallExpression || expr instanceof PropertyExpression)) return false + expr.implicitThis || (expr.objectExpression instanceof VariableExpression && expr.objectExpression.thisExpression) + } +} diff --git a/grails-doc/src/en/guide/introduction/whatsNew.adoc b/grails-doc/src/en/guide/introduction/whatsNew.adoc index 17f4bb42d3a..82c35f65140 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 8a7cea93b63..795f6ed531a 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/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 00000000000..0818a4f9d0a --- /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 { + + 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-test-examples/demo33/grails-app/controllers/demo/CompileStaticController.groovy b/grails-test-examples/demo33/grails-app/controllers/demo/CompileStaticController.groovy new file mode 100644 index 00000000000..59ebad7f99e --- /dev/null +++ b/grails-test-examples/demo33/grails-app/controllers/demo/CompileStaticController.groovy @@ -0,0 +1,40 @@ +/* + * 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.compiler.GrailsCompileStatic + +/** + * 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. + */ +@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 00000000000..7bf07b2f11e --- /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('') + } + + 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-test-examples/demo33/src/test/groovy/demo/CompileStaticControllerSpec.groovy b/grails-test-examples/demo33/src/test/groovy/demo/CompileStaticControllerSpec.groovy new file mode 100644 index 00000000000..553ca1d89be --- /dev/null +++ b/grails-test-examples/demo33/src/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.web.controllers.ControllerUnitTest +import spock.lang.Specification + +class CompileStaticControllerSpec extends Specification implements ControllerUnitTest { + + void setup() { + mockTagLibs FirstTagLib, SecondTagLib + } + + void 'controller with @GrailsCompileStatic can call a default-namespace tag directly'() { + when: + controller.invokeDefaultNamespaceTag() + + then: + response.text == '' + } + + void 'controller with @GrailsCompileStatic can call a tag via namespace dispatcher property'() { + when: + controller.invokeNamespacedTag() + + then: + response.text == 'BEFORE Hello From SecondTagLib AFTER' + } +}