diff --git a/end-to-end/README.md b/end-to-end/README.md
index 228030dc323..7d2dc6cfb67 100644
--- a/end-to-end/README.md
+++ b/end-to-end/README.md
@@ -36,6 +36,7 @@ applications at via `GRAILS_REPO_URL`.
| `legacy-commands-plugin` | A Grails 8 plugin whose legacy commands are recompiled under Groovy 5. |
| `legacy-commands` | A Grails 8 application that consumes both and runs their commands through the registry. |
| `spring-dependency-management` | A Grails 8 application that manages its versions with the legacy `io.spring.dependency-management` plugin instead of the Grails Gradle plugin's native `platform(grails-bom)`, as an upgraded Grails 7 application does. |
+| `taglib-index-incremental` | Builds a Grails 8 application **twice, without a clean**, to prove a renamed or deleted tag library cannot survive in the published tag library index. Incremental behaviour is the whole point, so it cannot be expressed by a project the core build builds once for itself. |
`legacy-g7-command-plugin` is deliberately excluded from `settings.gradle`. An included build would
substitute `org.apache.grails:grails-core` for this repository's Groovy 5 project, which is exactly
diff --git a/end-to-end/settings.gradle b/end-to-end/settings.gradle
index 28629497b9f..428275ad5b9 100644
--- a/end-to-end/settings.gradle
+++ b/end-to-end/settings.gradle
@@ -104,6 +104,10 @@ rootProject.name = 'grails-end-to-end'
include(
'legacy-commands',
'legacy-commands-plugin',
+ // Builds an application twice without a clean, to prove a renamed or deleted tag library
+ // cannot survive in the index that is published. Incremental behaviour against real
+ // published artifacts is not something the core build can express.
+ 'taglib-index-incremental',
// Belongs here rather than in grails-test-examples: it imports grails-bom as a Maven BOM
// through io.spring.dependency-management, which resolves imports in its own detached
// configuration. That bypasses any project substitution, so the import can only ever be
diff --git a/end-to-end/taglib-index-incremental/build.gradle b/end-to-end/taglib-index-incremental/build.gradle
new file mode 100644
index 00000000000..54d037171a2
--- /dev/null
+++ b/end-to-end/taglib-index-incremental/build.gradle
@@ -0,0 +1,54 @@
+/*
+ * 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.
+ */
+
+// Whether a tag library that has been renamed or deleted can survive in what a build publishes.
+//
+// Answering it needs a real application built twice against real published artifacts, without a
+// clean in between, because what is being tested is incremental behaviour: Gradle does not recompile
+// a source that has not changed, so anything written per class as it compiled would simply stay. The
+// core build cannot express that - its own test projects are built once, by the build running the
+// test - so it lives here, where an application resolves Grails the way an application does.
+plugins {
+ id 'groovy'
+ id 'org.apache.grails.buildsrc.properties'
+}
+
+dependencies {
+ // Versions come from the same BOM the framework publishes, so this harness never pins its own.
+ testImplementation platform("org.apache.grails:grails-bom:${project.findProperty('projectVersion') ?: version}")
+ // Deliberately not gradleTestKit(): it carries Gradle's own Groovy 4 onto the test classpath,
+ // which the Groovy 5 Spock this repository builds against cannot compile against. The nested
+ // build is driven through the repository's own wrapper instead, which is also the Gradle an
+ // application would use.
+ testImplementation 'org.spockframework:spock-core'
+ testImplementation 'org.apache.groovy:groovy'
+ testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
+}
+
+tasks.named('test', Test) {
+ useJUnitPlatform()
+ // The application the test builds resolves Grails from the same place this build does.
+ systemProperty 'grails.e2e.localMavenRepo',
+ rootProject.layout.projectDirectory.dir('../build/local-maven').asFile.absolutePath
+ systemProperty 'grails.e2e.version', project.findProperty('projectVersion') ?: version
+ systemProperty 'grails.e2e.gradlew',
+ rootProject.layout.projectDirectory.file('../gradlew').asFile.absolutePath
+ // Each case builds an application from scratch, so this is slow by nature. It belongs to a build
+ // that is already opt-in and already requires a publish, so it is not gated further.
+}
diff --git a/end-to-end/taglib-index-incremental/src/test/groovy/org/apache/grails/e2e/taglib/TagLibraryIndexIncrementalSpec.groovy b/end-to-end/taglib-index-incremental/src/test/groovy/org/apache/grails/e2e/taglib/TagLibraryIndexIncrementalSpec.groovy
new file mode 100644
index 00000000000..cd3e7fbcfe2
--- /dev/null
+++ b/end-to-end/taglib-index-incremental/src/test/groovy/org/apache/grails/e2e/taglib/TagLibraryIndexIncrementalSpec.groovy
@@ -0,0 +1,241 @@
+/*
+ * 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.apache.grails.e2e.taglib
+
+import java.util.zip.ZipFile
+
+import spock.lang.Specification
+import spock.lang.TempDir
+
+/**
+ * A tag library that has been renamed or deleted must not survive in what a build publishes.
+ *
+ *
The interesting case is the second build. Gradle does not recompile a source that has not
+ * changed, so anything written per class as it compiled would never be revisited and would simply
+ * stay - describing a tag library that no longer exists, and being packaged alongside the index that
+ * no longer describes it. Only a build run twice, without a clean, shows that.
+ *
+ *
Built against published artifacts rather than project dependencies, so what is exercised is the
+ * plugin an application actually applies.
+ */
+class TagLibraryIndexIncrementalSpec extends Specification {
+
+ private static final String INDEX = 'META-INF/grails/taglibs'
+
+ @TempDir
+ File projectDir
+
+ def setup() {
+ writeSettings()
+ writeBuild()
+ writeTagLib('AlphaTagLib', 'alpha', 'alphaTag')
+ writeTagLib('BetaTagLib', 'beta', 'betaTag')
+ }
+
+ void 'a deleted tag library is gone from the published index after a build with no clean'() {
+ given: 'a first build describing both'
+ build()
+
+ expect:
+ packagedDescriptor('demo.AlphaTagLib').isFile()
+ packagedDescriptor('demo.BetaTagLib').isFile()
+ packagedManifest().contains('demo.BetaTagLib')
+ jarNames().contains("${INDEX}/demo.BetaTagLib.properties" as String)
+
+ when: 'one is deleted and the project is built again, without a clean'
+ new File(projectDir, 'grails-app/taglib/demo/BetaTagLib.groovy').delete()
+ build()
+
+ then: 'it is described nowhere: not beside the index, not in it, not in the artifact'
+ !packagedDescriptor('demo.BetaTagLib').isFile()
+ !packagedManifest().contains('demo.BetaTagLib')
+ !jarNames().any { it.contains('BetaTagLib') }
+
+ and: 'and the one that remains is still described'
+ packagedDescriptor('demo.AlphaTagLib').isFile()
+ packagedManifest().contains('demo.AlphaTagLib')
+ }
+
+ void 'a renamed tag library does not leave its old name behind'() {
+ given:
+ build()
+
+ when: 'renamed in place, which to a build is a deletion and an addition'
+ new File(projectDir, 'grails-app/taglib/demo/BetaTagLib.groovy').delete()
+ writeTagLib('GammaTagLib', 'beta', 'betaTag')
+ build()
+
+ then:
+ !packagedDescriptor('demo.BetaTagLib').isFile()
+ packagedDescriptor('demo.GammaTagLib').isFile()
+ packagedManifest().contains('demo.GammaTagLib')
+ !packagedManifest().contains('demo.BetaTagLib')
+ }
+
+ void 'a tag removed from a tag library is gone from the index it is described by'() {
+ given:
+ build()
+
+ expect:
+ packagedDescriptor('demo.AlphaTagLib').text.contains('alphaTag')
+
+ when: 'the tag is removed and the project built again'
+ writeTagLib('AlphaTagLib', 'alpha', 'renamedTag')
+ build()
+
+ then:
+ !packagedDescriptor('demo.AlphaTagLib').text.contains('alphaTag:')
+ packagedDescriptor('demo.AlphaTagLib').text.contains('renamedTag')
+ }
+
+ void 'an executable archive carries the descriptors and not the settings'() {
+ given: 'a war copies whole directories off the runtime classpath, which is how the settings'
+ buildTask('war')
+
+ when: 'used to escape an exclusion declared on the archive task'
+ List entries = archiveNames('build/libs', '.war')
+
+ then: 'the descriptors are there, where a page compiled at runtime can read them'
+ entries.any { it == "WEB-INF/classes/${INDEX}/demo.AlphaTagLib.properties" as String }
+ entries.any { it == "WEB-INF/classes/${INDEX}/index.properties" as String }
+
+ and: 'the settings are nowhere in it'
+ !entries.any { it.endsWith('compile-settings.properties') }
+
+ and: 'and no descriptor is carried twice, in two places that would then disagree'
+ entries.findAll { it.endsWith('demo.AlphaTagLib.properties') }.size() == 1
+ }
+
+ void 'the settings this build declared reach no archive'() {
+ given: 'they say how this project compiles; a consumer inheriting them would compile by them'
+ build()
+
+ expect: 'not in the jar'
+ !jarNames().any { it.endsWith('compile-settings.properties') }
+
+ and: 'and not anywhere in the tree an executable archive is built from, which copies whole'
+ !new File(projectDir, 'build/generated/grails-taglibs-packaged')
+ .listFiles({ File dir, String name -> name == 'META-INF' } as FilenameFilter)
+ .collect { new File(it, 'grails/taglibs/compile-settings.properties') }
+ .any { it.exists() }
+ }
+
+ void 'nothing writes a second index into the class output'() {
+ given: 'a build that writes the index owns it, so a copy there could only compete and go stale'
+ build()
+
+ expect:
+ !new File(projectDir, "build/classes/groovy/main/${INDEX}").exists()
+ }
+
+ private void build() {
+ buildTask('jar')
+ }
+
+ private void buildTask(String task) {
+ Process process = new ProcessBuilder(System.getProperty('grails.e2e.gradlew'),
+ '-p', projectDir.absolutePath, task, '--stacktrace')
+ .redirectErrorStream(true)
+ .start()
+ String output = process.inputStream.getText('UTF-8')
+ int status = process.waitFor()
+ assert status == 0 : "building the application failed:\n${output}"
+ }
+
+ private File packagedDescriptor(String className) {
+ new File(projectDir, "build/generated/grails-taglibs-packaged/${INDEX}/${className}.properties")
+ }
+
+ private String packagedManifest() {
+ File manifest = new File(projectDir,
+ "build/generated/grails-taglibs-packaged/${INDEX}/index.properties")
+ manifest.isFile() ? manifest.text : ''
+ }
+
+ private List jarNames() {
+ archiveNames('build/libs', '.jar')
+ }
+
+ private List archiveNames(String directory, String extension) {
+ File archive = new File(projectDir, directory).listFiles()?.find { it.name.endsWith(extension) }
+ assert archive != null : "the project produced no ${extension} in ${directory}"
+ new ZipFile(archive).withCloseable { zip -> zip.entries().collect { it.name } }
+ }
+
+ private void writeTagLib(String className, String namespace, String tagName) {
+ File dir = new File(projectDir, 'grails-app/taglib/demo')
+ dir.mkdirs()
+ new File(dir, "${className}.groovy").text = """
+ package demo
+
+ import grails.gsp.TagLib
+
+ @TagLib
+ class ${className} {
+ static namespace = '${namespace}'
+
+ def ${tagName}(Map attrs) {
+ out << 'hello'
+ }
+ }
+ """
+ }
+
+ private void writeSettings() {
+ String repo = System.getProperty('grails.e2e.localMavenRepo')
+ new File(projectDir, 'settings.gradle').text = """
+ pluginManagement {
+ repositories {
+ maven { url = uri('${repo.replace('\\\\', '/')}') }
+ gradlePluginPortal()
+ mavenCentral()
+ }
+ }
+ dependencyResolutionManagement {
+ repositories {
+ maven { url = uri('${repo.replace('\\\\', '/')}') }
+ mavenCentral()
+ }
+ }
+ rootProject.name = 'taglib-index-incremental-app'
+ """
+ }
+
+ private void writeBuild() {
+ String version = System.getProperty('grails.e2e.version')
+ new File(projectDir, 'build.gradle').text = """
+ plugins {
+ id 'groovy'
+ id 'war'
+ id 'org.apache.grails.gradle.grails-gsp' version '${version}'
+ }
+
+ version = '0.1'
+ group = 'demo'
+
+ dependencies {
+ // The gsp plugin alone applies no BOM, so this names it the way an application does.
+ implementation platform('org.apache.grails:grails-bom:${version}')
+ implementation 'org.apache.grails.views:grails-web-taglib'
+ implementation 'org.apache.grails.views:grails-taglib'
+ implementation 'org.apache.grails.views:grails-gsp-core'
+ }
+ """
+ }
+}
diff --git a/gradle/rat-root-config.gradle b/gradle/rat-root-config.gradle
index 99153d107b0..cff51456fa8 100644
--- a/gradle/rat-root-config.gradle
+++ b/gradle/rat-root-config.gradle
@@ -124,6 +124,7 @@ tasks.named('rat') {
'grails-forge/**/src/main/resources/**', // src/main/resources are included in generated application and should not include a license
'grails-forge/**/src/test/resources/**', // src/test/resources are used in tests against files included in generated application and should not include a license
'grails-gradle/**/build/**', // grails-gradle does not have a build package name so exclude any build directories
+ 'end-to-end/**/build/**', // its own build, so its build directories are not covered by the root exclude
'grails-forge/*/build/**', // grails-forge build directories
'grails-forge/build/**', // grails-forge build directories
'grails-spring-security/plugin/src/main/templates/**', // template files that people are expected to use in the end application
diff --git a/grails-common/src/main/groovy/org/apache/grails/common/compiler/GroovyTransformOrder.groovy b/grails-common/src/main/groovy/org/apache/grails/common/compiler/GroovyTransformOrder.groovy
index 07785d04c5f..e6fe7773513 100644
--- a/grails-common/src/main/groovy/org/apache/grails/common/compiler/GroovyTransformOrder.groovy
+++ b/grails-common/src/main/groovy/org/apache/grails/common/compiler/GroovyTransformOrder.groovy
@@ -228,4 +228,11 @@ interface GroovyTransformOrder {
* contention, but a deterministic order keeps compilation output reproducible.
*/
static final int COMMAND_FACTORIES_ORDER = RX_SCHEDULER_ORDER + DECREMENT_PRIORITY
+
+ /**
+ * Rewrites a call to a known tag into a direct invocation. Runs last, because whether a class can
+ * call tags at all is only settled once the traits that let it have been applied, which is what
+ * the artefact transforms above do.
+ */
+ static final int COMPILED_TAG_CALL_ORDER = COMMAND_FACTORIES_ORDER + DECREMENT_PRIORITY
}
diff --git a/grails-core/src/main/groovy/org/grails/compiler/TagLibraryInvokerTypeCheckingExtension.groovy b/grails-core/src/main/groovy/org/grails/compiler/TagLibraryInvokerTypeCheckingExtension.groovy
index a5bad5ce676..bb6d5e320e7 100644
--- a/grails-core/src/main/groovy/org/grails/compiler/TagLibraryInvokerTypeCheckingExtension.groovy
+++ b/grails-core/src/main/groovy/org/grails/compiler/TagLibraryInvokerTypeCheckingExtension.groovy
@@ -69,6 +69,13 @@ import org.grails.core.artefact.ControllerArtefactHandler
* without error. Type-safety for method calls on declared fields and
* local variables is fully preserved.
*
+ * Calls to tags the tag library index knows never reach this extension at all: they are compiled
+ * into direct invocations before type checking runs, so the type checker sees ordinary resolved method
+ * calls. A misspelled tag is reported there instead, but only where the source says the call is a tag
+ * - one naming its namespace. An unqualified call is left dynamic and unjudged, here and there, since
+ * such a name may equally be a dynamic finder, an injected service method or anything else contributed
+ * while the application runs.
+ *
*
Composition with other extensions: because this is a catch-all
* handler for unresolved calls in controllers and tag libraries, it must run after any other
* type-checking extension that resolves DSL-style calls (e.g. a criteria extension).
diff --git a/grails-doc/src/en/guide/introduction/whatsNew.adoc b/grails-doc/src/en/guide/introduction/whatsNew.adoc
index e1f4502755d..97f48232d88 100644
--- a/grails-doc/src/en/guide/introduction/whatsNew.adoc
+++ b/grails-doc/src/en/guide/introduction/whatsNew.adoc
@@ -364,3 +364,47 @@ base name an application configures itself, neither of which Spring Boot's own r
Plugin message bundles must now be namespaced on the plugin name — `spring-security-core.properties`
rather than `messages.properties` — so that two plugins cannot shadow one another. See
<> for the details.
+
+=== Compiled Tag Resolution
+
+Tag libraries are now described when they are compiled, and that description resolves tag calls in
+pages, tag libraries and controllers compiled afterwards. In a tag library or a controller, a call
+whose namespace and tag are known is compiled into a direct invocation rather than being dispatched
+through the metaclass, and no tag methods are installed onto tag library, page or dispatcher
+metaclasses to make dispatch work:
+
+[source,groovy]
+----
+class BookController {
+ def index() {
+ String markup = g.link(controller: 'book') // compiled into a direct invocation
+ }
+}
+----
+
+The same applies to a call written without a namespace, to calls written inside a closure such as a
+tag body, and to a tag expression in a GSP declaring `compileStatic`. The tag itself is still selected
+by name when the call runs, so a tag library that overrides another, one registered while the
+application is running, and the order tag libraries are registered in all behave exactly as before. A
+namespace no compiled tag library declares, and a name something else in scope answers to, are left to
+dispatch as they did.
+
+A tag that no compiled tag library declares is left to resolve at runtime with nothing reported, since
+a namespace can legitimately hold tag libraries carrying no description. An application whose tag
+libraries are all described can ask for an error instead:
+
+[source,groovy]
+.build.gradle
+----
+grails {
+ compileStatic {
+ strictTags = true
+ dynamicTagNamespaces = ['legacy'] // registered while the application runs
+ }
+}
+----
+
+Defining a tag as a `Closure` field remains supported but is deprecated and now warns at compile time:
+a closure carries no signature, so nothing about a call to such a tag can be checked. Define tags as
+methods taking `Map attrs` and, where a body is needed, `Closure body`. See
+link:theWebLayer.html#compiledTags[Compiled Tag Resolution].
diff --git a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc
new file mode 100644
index 00000000000..577d234e1b8
--- /dev/null
+++ b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc
@@ -0,0 +1,308 @@
+////
+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.
+////
+
+Tag libraries are described when they are compiled, and that description is used to resolve tag calls
+in pages, tag libraries and controllers compiled afterwards.
+
+==== Defining tags as methods
+
+Define a tag as a method:
+
+[source,groovy]
+----
+class GreetingTagLib {
+
+ static namespace = 'greet'
+
+ def hello(Map attrs) {
+ out << "Hello ${attrs.name}"
+ }
+
+ def wrapped(Map attrs, Closure body) {
+ out << '' << body() << '
'
+ }
+}
+----
+
+The attributes parameter must be a `Map` named `attrs`, and the body parameter a `Closure` named
+`body`. A method taking anything else is an ordinary method of the tag library rather than a tag.
+Where that convention does not suit, `@Tag` marks a method as a tag whatever its signature and
+`@NotATag` excludes one that would otherwise match.
+
+The older form, a `Closure` field, still works:
+
+[source,groovy]
+----
+// Deprecated
+Closure hello = { Map attrs ->
+ out << "Hello ${attrs.name}"
+}
+----
+
+A tag declared this way is described and called like any other — the tag is selected by name when the
+call runs, and a closure answers to a name as readily as a method does. The form remains deprecated
+because a closure carries no signature, so nothing about the call can be checked, and because a
+closure field is inherited where a tag method is not, which makes what a tag library declares harder
+to read. Compiling a tag library that declares one produces a warning naming the tag.
+
+==== Calling tags
+
+In a tag library or a controller, a call to a tag whose namespace and name are known is compiled into
+a direct invocation rather than being dispatched through the metaclass:
+
+[source,groovy]
+----
+class BookController {
+ def index() {
+ String markup = g.createLink(controller: 'book') // compiled into a direct invocation
+ String other = greet.hello(name: 'Grails') // likewise
+ }
+}
+----
+
+The attributes and body are passed straight through where the call says what they are. Where it does
+not — attributes assembled at runtime, or a single value the tag reads under its own name — the
+arguments are forwarded as written and sorted out by the same rules dynamic dispatch applies:
+
+[source,groovy]
+----
+Map attrs = buildAttributes()
+g.createLink(attrs) // still compiled into an invocation
+----
+
+The tag is always selected by name when the call runs, through the same lookup dynamic dispatch uses.
+A tag library that overrides another, one registered while the application is running, and the order
+tag libraries are registered in all decide the outcome exactly as they did before. Nothing is bound to
+a particular tag library class, so a tag declared by more than one of them is compiled the same way.
+
+A call into a namespace no compiled tag library declares is left alone, which is what allows a tag
+library registered while an application is running to keep working.
+
+A controller declared by convention, under `grails-app/controllers`, has its tag calls compiled. One
+declared by annotation outside that directory does not:
+
+[source,groovy]
+----
+// src/main/groovy/demo/ReportController.groovy
+@Artefact('Controller')
+class ReportController {
+ def index() {
+ g.createLink(controller: 'book') // dispatched dynamically
+ }
+}
+----
+
+The ability to call tags reaches such a class from the `@Artefact` annotation, which is applied later
+in the compilation than the rewriting runs, so the rewriting cannot see that the class calls tags. The
+call behaves exactly as it did before this release; it simply does not take the faster path. Moving
+the class under `grails-app/controllers` gets it compiled.
+
+A name that something else in scope already answers to is not a namespace. A local variable, a
+parameter or a property called `g` is that thing, and a call on it is left alone:
+
+[source,groovy]
+----
+def index() {
+ def g = someClient
+ g.createLink(controller: 'book') // someClient.createLink, not the tag
+}
+----
+
+A call written without a namespace is not compiled unless the build asks for it. Whether a bare name
+is a tag depends on what else answers to it, and not all of that is visible when compiling: a method
+Groovy gives every object, a delegate an enclosing closure is handed when it runs, an overload the tag
+library also declares. Such a call is dispatched as it always was:
+
+[source,groovy]
+----
+class BookController {
+ def index() {
+ String markup = createLink(controller: 'book') // dispatched dynamically
+ String other = g.createLink(controller: 'book') // compiled into an invocation
+ }
+}
+----
+
+A project whose tag names are known not to collide can compile those calls too:
+
+[source,groovy]
+.build.gradle
+----
+grails {
+ compileStatic {
+ unqualifiedTagCalls = true
+ }
+}
+----
+
+Turning it on widens which calls are considered, not which names may be captured: a name that Groovy,
+a local, a field, a parameter or the calling class answers to is still left alone.
+
+A call that names its namespace is compiled wherever it appears, including inside a closure. The
+namespace itself is a name a closure's delegate could in principle answer to, and that is not checked
+— the guard above applies to the tag name, not to the namespace in front of it. No delegate in
+ordinary use answers to a namespace name, so a call written as `g.createLink(...)` reaches the tag as
+written; a DSL whose delegate did answer to `g` would be the exception.
+
+==== Tags in pages
+
+A page resolves a name against the model it was rendered with before it reaches a tag library, and
+that model is not known when the page is compiled. A page therefore keeps resolving its tags as it
+always has, unless it declares `compileStatic`:
+
+[source,html]
+----
+<%@ page compileStatic="true" %>
+${g.createLink(controller: 'book')} <%-- compiled into a direct invocation --%>
+----
+
+Declaring `compileStatic` on a page reserves the namespace names for tag libraries: a model attribute
+called `g` no longer shadows the `g` namespace there. Without it, an expression is dispatched exactly
+as before. Set `grails.views.gsp.compileStatic` in configuration to apply it to every page.
+
+Two things hold in a page either way. A call written without a namespace, as
+`${createLink(controller: 'book')}` is, is always left to resolve against the binding. And a name the
+page puts into its own binding is that variable rather than a namespace:
+
+[source,html]
+----
+
+${g.createLink(controller: 'book')} <%-- someObject.createLink, not the tag --%>
+----
+
+A tag written as markup, as `` is, already compiles into a direct
+call naming the tag and needs no rewriting. It is unambiguously a tag whatever the page does, so under
+strict checking it is checked in every page. An expression is checked only where it is resolved, in a
+page declaring `compileStatic`, since elsewhere the receiver may be part of the model.
+
+==== Reporting unknown tags
+
+By default nothing is reported: a tag that no compiled tag library declares is left to resolve at
+runtime, exactly as it did before. A namespace can hold tag libraries that were not compiled with a
+description — a plugin built against an earlier version of Grails contributes tags to `g` without one,
+and a tag library registered at runtime contributes more — so a tag missing from the description is
+not necessarily a misspelling, and reporting one by default would mean complaining about correct code.
+
+An application whose tag libraries are all described can ask for an error instead:
+
+[source,groovy]
+.build.gradle
+----
+grails {
+ compileStatic {
+ strictTags = true
+ dynamicTagNamespaces = ['legacy'] // <1>
+ }
+}
+----
+<1> namespaces genuinely filled in while the application runs
+
+Strict checking applies where the source says a call is a tag: one naming its namespace, as
+`g.message(code: 'x')` does, and one written as markup, as `` is. A call written without a
+namespace is never checked — such a name may equally be a method contributed while the application
+runs, and in a page it may come from the model. A namespaced expression in a page is checked only when
+that page declares `compileStatic`, for the same reason its calls are only rewritten there.
+
+`dynamicTagNamespaces` names the namespaces whose tags are decided while the application runs rather
+than described when it is compiled. It turns compile-time resolution off for them completely: a call
+into such a namespace is never rewritten, never reported, and is dispatched exactly as it was before
+this release, whether or not a compiled tag library also declares the namespace. Declare a namespace
+here when a tag library is registered at runtime, or when the tags in it are contributed by
+metaprogramming.
+
+Both settings are read from the build, not from a system property, so changing either recompiles what
+depends on it.
+
+===== What strict checking covers
+
+Strict checking applies to the namespaces this project's own tag libraries declare. Those are the only
+namespaces whose contents are knowable when compiling: a tag missing from one of them is a misspelling,
+because nothing else contributes to it.
+
+Every other namespace is left alone, `g` included. A plugin built against an earlier version of Grails
+contributes tags to `g` with no description, as does one that declares its tag libraries by convention
+without applying the GSP Gradle plugin, and a tag library registered while the application runs
+contributes more. A tag missing from such a namespace is as likely to be one of those as a mistake, so
+reporting it would fail a build over correct code.
+
+So `strictTags` catches a misspelling of your own tags, in your own namespaces, and never complains
+about a plugin's. Nothing has to be listed in `dynamicTagNamespaces` to get that — it remains for the
+narrower case of a namespace *this project* declares but fills in while the application runs.
+
+An index generated from source before compilation also records what it could not describe, and no tag
+in a namespace it failed to read completely is reported either.
+
+==== Where the description lives
+
+Each tag library contributes one file under `META-INF/grails/taglibs` in the artifact it is packaged
+in. Descriptions from every jar on the classpath are combined, so a plugin's tag libraries are
+resolvable by an application that depends on it without any extra build configuration.
+
+Under the Grails Gradle plugin the description is written twice, because the two things that read it
+need different guarantees.
+
+`generateTagLibraryIndex` runs before compilation and reads the sources under `grails-app/taglib`, so
+tags an application declares are resolvable in the same compilation that defines them. Being read from
+source it cannot describe everything: a tag library referring to a type written in Java, or generated
+by the build, is left out. What it missed is recorded, and nothing in a namespace it could not fully
+describe is ever reported as an unknown tag, so strict checking cannot fail a build over a tag that
+does exist. This index is used only to compile this project, and is never packaged.
+
+`packageTagLibraryIndex` runs after compilation, with the project's own classes on its classpath,
+where every tag library resolves whatever language its collaborators were written in. That index is
+the authoritative one: pages are compiled against it, it travels with the artifact, and a project
+depending on this one reads it. Every run replaces the directory, so a tag library that is renamed or
+deleted disappears from it.
+
+A project keeping tag libraries elsewhere adds those directories once, by task type, so that both
+indexes describe the same set — configuring them separately would let the index this project compiles
+against differ from the one it publishes:
+
+[source,groovy]
+.build.gradle
+----
+import org.grails.gradle.plugin.views.gsp.GenerateTagLibraryIndexTask
+
+tasks.withType(GenerateTagLibraryIndexTask).configureEach {
+ sourceDirectories.from(file('src/main/groovy'))
+}
+----
+
+Before compilation, a tag library referring to something the same project declares — a service it
+injects, a base class it extends, a trait it carries — is described too, by reading that Groovy source
+alongside it, so an inherited namespace, a tag a trait contributes and an attributes parameter of a
+project-declared type are all read rather than guessed. A tag library naming a type that does not
+exist is left out, as it would be by the compiler.
+
+Where no build writes the index — a plain Groovy compilation, or one that does not apply the Grails
+Gradle plugin — a tag library annotated `@TagLib` describes itself as it is compiled instead, which
+makes it resolvable to anything compiled after it.
+
+That fallback reaches annotated tag libraries only. A tag library declared by convention, as an
+unannotated class under `grails-app/taglib`, is recognised as an artefact too late in the compilation
+for it to describe itself, so without the Gradle plugin it contributes no description. Nothing breaks:
+a tag with no description is dispatched dynamically, exactly as it was before any of this existed. But
+a plugin that declares its tag libraries by convention and does not apply the Grails GSP Gradle plugin
+publishes no descriptors, and its tags are resolved at runtime in applications that depend on it.
+Annotate those tag libraries with `@TagLib`, or apply the plugin, to have them described.
+
+A descriptor written this way is also never removed. Renaming or deleting a tag library leaves its
+description behind until the build directory is cleaned, and a description naming a class that no
+longer exists puts tags into the index that nothing will answer to. Builds using the Gradle plugin do
+not have this problem: the task rewrites the index from the sources each time it runs.
diff --git a/grails-doc/src/en/guide/toc.yml b/grails-doc/src/en/guide/toc.yml
index d7d891c090d..1918e378cad 100644
--- a/grails-doc/src/en/guide/toc.yml
+++ b/grails-doc/src/en/guide/toc.yml
@@ -155,6 +155,7 @@ theWebLayer:
logicalTags: Logical Tags
iterativeTags: Iterative Tags
namespaces: Tag Namespaces
+ compiledTags: Compiled Tag Resolution
usingJSPTagLibraries: Using JSP Tag Libraries
tagReturnValue: Tag return value
fields:
diff --git a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc
index a799bc829a9..fe16cdfe3f0 100644
--- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc
+++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc
@@ -2632,3 +2632,190 @@ used to apply to its own message source.
Adding or removing a base name now needs a restart, because Spring Boot reads the configured base names
once when it builds the message source.
+
+==== 48. Tag Libraries Are Described When Compiled
+
+Tag calls are resolved against a description each tag library contributes as it is compiled, and a
+resolved call is compiled into a direct invocation. The tag itself is still selected by name when the
+call runs, through the same lookup dynamic dispatch uses, so a tag library that overrides another and
+the order tag libraries are registered in behave as before. A call into a namespace no compiled tag
+library declares — a tag library from a plugin built against an earlier version of Grails, or one
+registered while the application runs — is left to dispatch exactly as it did.
+
+Three things are worth knowing when upgrading.
+
+A tag that no compiled tag library declares is left to resolve at runtime, and nothing is reported.
+Setting `grails { compileStatic { strictTags = true } }` makes it a compilation error instead, which
+is worth doing once to find misspelled tags, but is not the default because a namespace can
+legitimately hold tag libraries that carry no description. Where an application registers tag
+libraries while it runs, name their namespaces in
+`grails { compileStatic { dynamicTagNamespaces = [...] } }` so that they are never checked.
+
+Strict checking applies only where the source says a call is a tag: one naming its namespace, and one
+written as markup. A call written without a namespace is never checked, and a namespaced expression in
+a page is checked only when that page declares `compileStatic`.
+
+A call written without a namespace is not compiled at all unless the build asks for it with
+`grails { compileStatic { unqualifiedTagCalls = true } }`. Whether a bare name is a tag depends on what
+else answers to it, and not all of that is visible when compiling — a method Groovy gives every object,
+a delegate an enclosing closure is handed, an overload the tag library also declares — so by default
+such a call is dispatched exactly as it was before this release.
+
+Where it is turned on, a call written without a namespace reaches a tag only when nothing nearer
+answers to the name: not a method of the class, not one it inherits, not a field, property or local,
+not a method Groovy provides, and not a name inside a closure, whose delegate is only known when it
+runs. A method added to a controller or tag library *while the application runs*, through a plugin's
+`doWithDynamicMethods`, is not visible when the calling code is compiled, so a call that used to reach
+such a method and shares its name with a tag would reach the tag instead. Declare the method on the
+class, name the namespace in `dynamicTagNamespaces`, or call the tag with its namespace.
+
+Naming a namespace in `dynamicTagNamespaces` turns rewriting off for it entirely, not just the
+reporting: calls into it are dispatched exactly as they were before this release. That is the escape
+hatch for a namespace whose tags are decided while the application runs.
+
+Tags defined as closures now warn at compile time. They still work and are called the same way, but a
+closure carries no signature, so nothing about a call to such a tag can be checked. This covers the
+`def` form as well as the explicitly typed one, and the `def` form is the one most tag libraries are
+written in:
+
+[source,groovy]
+----
+// Before - both forms warn
+def hello = { attrs ->
+ out << "Hello ${attrs.name}"
+}
+
+Closure goodbye = { Map attrs ->
+ out << "Goodbye ${attrs.name}"
+}
+
+// After
+def hello(Map attrs) {
+ out << "Hello ${attrs.name}"
+}
+
+def goodbye(Map attrs) {
+ out << "Goodbye ${attrs.name}"
+}
+----
+
+A tag taking a body becomes a method with a second `Closure body` parameter:
+
+[source,groovy]
+----
+// Before
+def wrapped = { attrs, body ->
+ out << '' << body() << '
'
+}
+
+// After
+def wrapped(Map attrs, Closure body) {
+ out << '' << body() << '
'
+}
+----
+
+===== Tags Are No Longer Installed Onto Metaclasses
+
+Dispatching a tag no longer works by installing a method for every tag, and a property for every
+namespace, onto the metaclass of every tag library, controller and page. Tags are resolved through the
+tag library lookup instead. Calling a tag — from a page, a tag library or a controller, with or
+without its namespace — is unaffected.
+
+What changes is code that inspected the metaclass to find tags. A check such as
+
+[source,groovy]
+----
+tagLib.metaClass.respondsTo(tagLib, 'someTag')
+----
+
+answered `true` before because the tag had been installed there, and now answers `false`. Call the tag,
+or consult the tag library lookup, instead of asking the metaclass what it holds.
+
+The methods that performed the installation are deprecated or removed:
+
+[cols="2,3"]
+|===
+|Member |Replacement
+
+|`NamespacedTagDispatcher.initializeMetaClass()`
+|Removed. Nothing needs to be initialised.
+
+|`NamespacedTagDispatcher.registerTagMetaMethods(ExpandoMetaClass)`
+|Removed.
+
+|`TemplateNamespacedTagDispatcher.registerTagMetaMethods(ExpandoMetaClass)`
+|Removed.
+
+|`GroovyPagesMetaUtils.registerMethodMissingForGSP(...)`
+|Retained but does nothing. A page now declares `methodMissing` itself.
+
+|`TagLibraryMetaUtils.enhanceTagLibMetaClass`, `registerTagMetaMethods`, `registerMethodMissingForTags`, `registerNamespaceMetaProperties`, `registerPropertyMissingForTag`, `addTagLibMethodToMetaClass`
+|Deprecated. Unit test support still uses them so that a tag method can be called directly on a tag library under test.
+|===
+
+`TagLibraryMetaUtils.methodMissingForTagLib` is not deprecated — it is the dynamic dispatch path a call
+into an undescribed namespace still takes.
+
+===== A Method-Declared Tag Called Without a Namespace Returns Its Output
+
+A class that can call tags but is not a tag library — a controller — reaches a tag written without a
+namespace through `methodMissing` on the `TagLibraryInvoker` trait. That used to end in a direct call
+on the tag library bean. For a tag declared as a closure this made no difference, because the call
+landed on a generated wrapper that captured output anyway; for one declared as a method there was no
+wrapper, so the method ran with nothing captured and its own return value came back.
+
+Both forms now capture, so a method-declared tag returns what it wrote:
+
+[source,groovy]
+----
+class ReportTagLib {
+ static namespace = 'g'
+
+ def summary(Map attrs) {
+ out << 'the output'
+ 'the return value' // <1>
+ }
+}
+
+class ReportController {
+ def index() {
+ String result = summary(id: 1) // 'the output', previously 'the return value'
+ }
+}
+----
+<1> a tag's return value is not what a caller receives; what it writes is
+
+A tag called *with* its namespace, and any tag called from a GSP, already captured, so only an
+unqualified call from a controller to a method-declared tag changes.
+
+===== A Tag the Runtime Cannot Resolve Reports a Different Exception
+
+A call compiled into a direct invocation reports a tag the runtime has not registered as a
+`GrailsTagException` rather than a `MissingMethodException`. This arises where the compiled index knows
+a tag but the running application does not have it: the plugin declaring it was excluded, the tag
+library is listed in `nonEnhancedTagLibClasses`, or a unit test mocked only some tag libraries.
+
+Code that catches `MissingMethodException` around a tag call, or probes with `respondsTo` before
+calling, behaves differently as a result:
+
+[source,groovy]
+----
+// Before
+try {
+ out << g.someTag(code: 'x')
+}
+catch (MissingMethodException ignored) {
+ out << fallback()
+}
+
+// After - the invocation reports the unresolved tag as a tag error
+try {
+ out << g.someTag(code: 'x')
+}
+catch (GrailsTagException ignored) {
+ out << fallback()
+}
+----
+
+A call into a namespace no compiled tag library describes is dispatched dynamically and still reports
+`MissingMethodException`, so only calls the build resolved are affected.
diff --git a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsCompileStaticOptions.groovy b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsCompileStaticOptions.groovy
index e9199e30c65..ddf086704a7 100644
--- a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsCompileStaticOptions.groovy
+++ b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsCompileStaticOptions.groovy
@@ -24,6 +24,7 @@ import groovy.transform.CompileStatic
import org.gradle.api.model.ObjectFactory
import org.gradle.api.provider.Property
+import org.gradle.api.provider.SetProperty
/**
* Lazy opt-ins for compiling Grails artefacts with {@code @GrailsCompileStatic} automatically,
@@ -80,11 +81,63 @@ class GrailsCompileStaticOptions implements Serializable {
*/
final Property tagLibs
+ /**
+ * Whether a tag no compiled tag library declares should fail compilation. Disabled by default,
+ * where such a tag is left to resolve at runtime with nothing reported.
+ *
+ * Checked only where the source says a call is a tag: one naming its namespace, as
+ * {@code g.message(code: 'x')} does, and one written as markup, as {@code } is. A call
+ * written without a namespace is not checked, because such a name may equally be a method
+ * contributed by any of the dynamic mechanisms an application has, and in a page it may be part of
+ * the model the page was rendered with.
+ *
+ *
Knowing that a namespace holds some compiled tag libraries is not the same as knowing it
+ * holds all of them: a plugin built before tag library descriptors existed contributes tags
+ * without one, and a tag library registered while an application runs contributes more. Enable
+ * this once every tag library an application uses is described, and declare the namespaces that
+ * are genuinely filled in at runtime through {@link #getDynamicTagNamespaces() dynamicTagNamespaces}:
+ *
+ *
+ * grails {
+ * compileStatic {
+ * strictTags = true
+ * dynamicTagNamespaces = ['legacy']
+ * }
+ * }
+ *
+ *
+ * @since 8.0
+ */
+ /**
+ * Whether a tag call written without its namespace may be compiled into a direct invocation.
+ *
+ * Off by default. A namespaced call names the tag library it means; a bare name is a tag only
+ * when nothing nearer answers to it, and what answers to it is not fully visible when compiling -
+ * a method Groovy gives every object, a delegate an enclosing closure is handed, an overload the
+ * tag library also declares. Turn this on to compile those calls too, in a project whose tag
+ * names are known not to collide.
+ */
+ final Property unqualifiedTagCalls
+
+ final Property strictTags
+
+ /**
+ * Namespaces whose tag libraries are registered while the application runs rather than described
+ * when it is compiled. Tags in them are never reported as unknown, however complete the tag
+ * library index is, and calls to them keep being dispatched dynamically.
+ *
+ * @since 8.0
+ */
+ final SetProperty dynamicTagNamespaces
+
@Inject
GrailsCompileStaticOptions(ObjectFactory objects) {
this.all = objects.property(Boolean).convention(false)
this.controllers = objects.property(Boolean).convention(false)
this.services = objects.property(Boolean).convention(false)
this.tagLibs = objects.property(Boolean).convention(false)
+ this.strictTags = objects.property(Boolean).convention(false)
+ this.unqualifiedTagCalls = objects.property(Boolean).convention(false)
+ this.dynamicTagNamespaces = objects.setProperty(String).convention(Collections. emptySet())
}
}
diff --git a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/views/gsp/GenerateTagLibraryIndexTask.groovy b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/views/gsp/GenerateTagLibraryIndexTask.groovy
new file mode 100644
index 00000000000..8c20c33ec0f
--- /dev/null
+++ b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/views/gsp/GenerateTagLibraryIndexTask.groovy
@@ -0,0 +1,206 @@
+/*
+ * 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.gradle.plugin.views.gsp
+
+import javax.inject.Inject
+
+import groovy.transform.CompileStatic
+import org.gradle.api.DefaultTask
+import org.gradle.api.file.ConfigurableFileCollection
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.provider.Property
+import org.gradle.api.tasks.CacheableTask
+import org.gradle.api.tasks.Classpath
+import org.gradle.api.tasks.IgnoreEmptyDirectories
+import org.gradle.api.tasks.Input
+import org.gradle.api.tasks.InputFiles
+import org.gradle.api.tasks.Optional
+import org.gradle.api.tasks.OutputDirectory
+import org.gradle.api.provider.SetProperty
+import org.gradle.api.tasks.Nested
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+import org.gradle.jvm.toolchain.JavaLauncher
+import org.gradle.api.tasks.util.PatternSet
+import org.gradle.process.ExecOperations
+import org.gradle.process.JavaExecSpec
+
+/**
+ * Writes the tag library index describing the tag libraries in this project.
+ *
+ * The index has to exist before anything that resolves tag calls is compiled, which is why this
+ * runs ahead of compilation rather than being produced as a side effect of it. Generating it for the
+ * whole source set at once is also what lets a renamed or deleted tag library disappear from it,
+ * where an index accumulated class by class keeps describing tags that no longer exist.
+ *
+ *
The work runs in a forked process against the project's own compile classpath, because the rules
+ * that decide what a tag is belong to the framework being built rather than to the build tooling, and
+ * must be the same rules the application applies when it starts.
+ *
+ * @since 8.0.0
+ */
+@CacheableTask
+@CompileStatic
+abstract class GenerateTagLibraryIndexTask extends DefaultTask {
+
+ static final String GENERATOR_CLASS = 'org.grails.taglib.index.TagLibraryIndexGenerator'
+
+ private final ExecOperations execOperations
+
+ @Inject
+ GenerateTagLibraryIndexTask(ExecOperations execOperations) {
+ this.execOperations = execOperations
+ description = 'Generates the tag library index used to resolve tag calls at compile time'
+ group = 'build'
+ }
+
+ /**
+ * The directories holding tag library sources.
+ *
+ *
Defaults to {@code grails-app/taglib}. A project keeping tag libraries elsewhere can add
+ * those directories, which is what makes them resolvable in the same compilation that defines
+ * them; without that they are still described as they compile, and so are resolvable to whatever
+ * is compiled afterwards.
+ */
+ @InputFiles
+ @IgnoreEmptyDirectories
+ @PathSensitive(PathSensitivity.RELATIVE)
+ abstract ConfigurableFileCollection getSourceDirectories()
+
+ /**
+ * The source roots a type this project declares may be resolved from.
+ *
+ *
A tag library commonly refers to a service, base class or trait of the same project, none of
+ * which exist as classes yet. Their source is compiled alongside it so that what they contribute -
+ * a namespace, tags, a parameter type - is read rather than guessed.
+ */
+ @InputFiles
+ @IgnoreEmptyDirectories
+ @PathSensitive(PathSensitivity.RELATIVE)
+ abstract ConfigurableFileCollection getResolutionSourceRoots()
+
+ /**
+ * Where the index is written. Placed on the compile classpath and packaged with the artifact.
+ */
+ @OutputDirectory
+ abstract DirectoryProperty getDestinationDirectory()
+
+ /**
+ * Where the settings this build declared are written.
+ *
+ *
Kept apart from the descriptors because the two travel differently: the descriptors are
+ * published, and the settings say how this project is compiled and must reach no one else. Sharing
+ * a directory would put them wherever the descriptors go, including into an executable archive
+ * built from the runtime classpath, where no exclusion on an archive task can reach them.
+ *
+ *
Written beside the descriptors when unset, which suits a caller with nothing to publish.
+ */
+ @OutputDirectory
+ @Optional
+ abstract DirectoryProperty getSettingsDirectory()
+
+ /**
+ * The classpath the generator runs against, which supplies the framework's discovery rules.
+ */
+ @Classpath
+ abstract ConfigurableFileCollection getGeneratorClasspath()
+
+ /**
+ * Whether this compilation writes parameter names into class files. It decides whether a tag's
+ * attributes and body parameters have to carry those names to be dispatchable, so the index must
+ * be generated under the same setting the sources are compiled with.
+ */
+ @Input
+ abstract Property getParameterNamesRetained()
+
+ /**
+ * The source encoding, matching the one compilation uses.
+ */
+ @Input
+ @Optional
+ abstract Property getSourceEncoding()
+
+ /**
+ * Whether a tag no compiled tag library declares fails compilation rather than being reported as a
+ * warning. Recorded alongside the index, where the compiler reads it.
+ */
+ @Input
+ abstract Property getStrictTags()
+
+ /**
+ * Whether a tag call written without its namespace may be compiled into a direct invocation.
+ * Recorded alongside the index, where the compiler reads it.
+ */
+ @Input
+ abstract Property getUnqualifiedTagCalls()
+
+ /**
+ * Namespaces the build declares as filled in while the application runs. Tags in them are never
+ * reported as unknown.
+ */
+ @Input
+ abstract SetProperty getDynamicTagNamespaces()
+
+ /**
+ * The Java the index is generated with. It runs against the project's own compile classpath, so it
+ * has to be the Java that classpath was built for rather than whichever one happens to be running
+ * Gradle.
+ */
+ @Nested
+ abstract Property getJavaLauncher()
+
+ @TaskAction
+ void generate() {
+ File destination = destinationDirectory.get().asFile
+ destination.mkdirs()
+ List directories = new ArrayList(sourceDirectories.files.findAll { File dir -> dir.isDirectory() })
+ // A directory that exists but holds no sources is not worth forking a process to read, and a
+ // project with no tag libraries at all must not need the generator on its classpath to build.
+ if (directories && !sourceDirectories.asFileTree.matching(new PatternSet().include('**/*.groovy')).empty) {
+ List roots = new ArrayList(resolutionSourceRoots.files.findAll { File dir -> dir.isDirectory() })
+ List arguments = [
+ destination.canonicalPath,
+ String.valueOf(parameterNamesRetained.getOrElse(true)),
+ sourceEncoding.getOrElse('UTF-8'),
+ String.valueOf(directories.size())
+ ]
+ arguments.addAll(directories.collect { File source -> source.canonicalPath })
+ arguments.addAll(roots.collect { File root -> root.canonicalPath })
+ // One process for every source directory at once: the generator rewrites the index in
+ // full, so a second process would erase what the first wrote.
+ execOperations.javaexec { JavaExecSpec spec ->
+ spec.mainClass.set(GENERATOR_CLASS)
+ spec.classpath = generatorClasspath
+ if (javaLauncher.present) {
+ spec.executable = javaLauncher.get().executablePath.asFile.absolutePath
+ }
+ spec.args(arguments)
+ }.assertNormalExitValue()
+ }
+ else {
+ TagLibraryIndexFiles.clearIndex(destination)
+ }
+ File settingsDestination = settingsDirectory.present ? settingsDirectory.get().asFile : destination
+ settingsDestination.mkdirs()
+ TagLibraryIndexFiles.writeSettings(settingsDestination, strictTags.getOrElse(false),
+ dynamicTagNamespaces.getOrElse([] as Set), unqualifiedTagCalls.getOrElse(false),
+ TagLibraryIndexFiles.readNamespaces(destination))
+ }
+}
diff --git a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/views/gsp/GroovyPagePlugin.groovy b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/views/gsp/GroovyPagePlugin.groovy
index 1f46366f9be..466edd18246 100644
--- a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/views/gsp/GroovyPagePlugin.groovy
+++ b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/views/gsp/GroovyPagePlugin.groovy
@@ -23,12 +23,15 @@ import groovy.transform.CompileStatic
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.file.CopySpec
+import groovy.transform.CompileDynamic
+import org.gradle.api.tasks.compile.GroovyCompile
import org.gradle.api.file.Directory
import org.gradle.api.file.DuplicatesStrategy
import org.gradle.api.file.FileCollection
import org.gradle.api.plugins.JavaPluginExtension
import org.gradle.api.provider.Provider
import org.gradle.api.tasks.SourceSet
+import org.gradle.api.tasks.SourceSetContainer
import org.gradle.api.tasks.SourceSetOutput
import org.gradle.api.tasks.TaskContainer
import org.gradle.api.tasks.bundling.Jar
@@ -47,6 +50,11 @@ import org.grails.gradle.plugin.util.SourceSets
@CompileStatic
class GroovyPagePlugin implements Plugin {
+ /**
+ * The test source sets a Grails project may define, each of which renders pages.
+ */
+ private static final List TEST_SOURCE_SET_NAMES = ['test', 'integrationTest']
+
@Override
void apply(Project project) {
project.pluginManager.withPlugin('groovy') {
@@ -54,6 +62,105 @@ class GroovyPagePlugin implements Plugin {
}
}
+ /**
+ * Whether compilation keeps parameter names, which decides whether a tag's attributes and body
+ * parameters have to carry those names to be dispatchable. The index has to be generated under the
+ * same setting the sources are compiled with, or it would describe a different set of tags.
+ */
+ @CompileDynamic
+ private static Provider resolvePreserveParameterNames(Project project) {
+ project.provider {
+ Object grails = project.extensions.findByName('grails')
+ Object preserve = grails?.hasProperty('preserveParameterNames') ? grails.preserveParameterNames : null
+ if (preserve instanceof Provider) {
+ return ((Provider) preserve).getOrElse(true) as Boolean
+ }
+ preserve == null ? Boolean.TRUE : (preserve as Boolean)
+ }
+ }
+
+ /**
+ * Whether the build declared that every tag library it uses is described at compile time, so that
+ * a tag missing from the index is a mistake rather than something contributed later.
+ */
+ @CompileDynamic
+ private static Provider resolveStrictTags(Project project) {
+ project.provider {
+ Object compileStatic = project.extensions.findByName('grails')?.compileStatic
+ Object strict = compileStatic?.hasProperty('strictTags') ? compileStatic.strictTags : null
+ strict instanceof Provider ? ((Provider) strict).getOrElse(false) as Boolean : Boolean.FALSE
+ }
+ }
+
+ /**
+ * Whether a tag call written without its namespace may be compiled into a direct invocation.
+ */
+ @CompileDynamic
+ private static Provider resolveUnqualifiedTagCalls(Project project) {
+ project.provider {
+ Object compileStatic = project.extensions.findByName('grails')?.compileStatic
+ Object unqualified = compileStatic?.hasProperty('unqualifiedTagCalls') ?
+ compileStatic.unqualifiedTagCalls : null
+ unqualified instanceof Provider ?
+ ((Provider) unqualified).getOrElse(false) as Boolean : Boolean.FALSE
+ }
+ }
+
+ /**
+ * The namespaces the build declared as filled in while the application runs.
+ */
+ @CompileDynamic
+ private static Provider> resolveDynamicTagNamespaces(Project project) {
+ project.provider {
+ Object compileStatic = project.extensions.findByName('grails')?.compileStatic
+ Object namespaces = compileStatic?.hasProperty('dynamicTagNamespaces') ?
+ compileStatic.dynamicTagNamespaces : null
+ namespaces instanceof Provider ?
+ (((Provider) namespaces).getOrElse([] as Set) as Set) : ([] as Set)
+ }
+ }
+
+ /**
+ * The encoding the project's Groovy sources are compiled with, which the generator has to read
+ * them with. Falls back to the generator's own default when the project has not set one.
+ */
+ @CompileDynamic
+ private static Provider resolveCompileEncoding(Project project) {
+ project.provider {
+ Object compile = project.tasks.findByName('compileGroovy')
+ (compile instanceof GroovyCompile) ? ((GroovyCompile) compile).options.encoding : null
+ }
+ }
+
+ /**
+ * The Groovy source roots of a source set, which is where a type this project declares is found.
+ */
+ @CompileDynamic
+ private static Set resolveGroovySourceRoots(SourceSet sourceSet) {
+ Object groovy = sourceSet?.extensions?.findByName('groovy')
+ groovy ? (groovy.srcDirs as Set) : ([] as Set)
+ }
+
+ /**
+ * Puts the packaged index onto the runtime classpath of every test source set, so that a page
+ * rendered by a test resolves its tags against the same index as the same page in production.
+ */
+ @CompileDynamic
+ private static void addPackagedIndexToTestRuntime(Project project, FileCollection packagedTagLibIndex) {
+ SourceSetContainer sourceSets = project.extensions.findByType(SourceSetContainer)
+ if (sourceSets == null) {
+ return
+ }
+ // Matched as they are created rather than looked up now. This runs on the groovy plugin being
+ // applied, and integrationTest is registered by the Grails integration test support later, so
+ // asking for it here would find nothing and skip it without saying so - which is the gap this
+ // method exists to close.
+ sourceSets.matching { SourceSet it -> it.name in TEST_SOURCE_SET_NAMES }
+ .configureEach { SourceSet it ->
+ it.runtimeClasspath = it.runtimeClasspath.plus(packagedTagLibIndex)
+ }
+ }
+
private void configureProject(Project project) {
TaskContainer tasks = project.tasks
@@ -64,20 +171,127 @@ class GroovyPagePlugin implements Plugin {
Provider webappDestDir = project.layout.buildDirectory.dir('gsp-classes/webapp')
output?.dir('gsp-classes')
+ // The Java the rest of the project is built with, so that pages are built with it too.
+ // Absent a toolchain this resolves to the JVM running Gradle, which is what compiling
+ // pages fell back to before and remains the right answer when nothing else was asked for.
+ JavaPluginExtension javaExtension = project.extensions.getByType(JavaPluginExtension)
+ JavaToolchainService toolchains = project.extensions.getByType(JavaToolchainService)
+ Provider launcher = toolchains.launcherFor(javaExtension.toolchain)
+
+ // The index is written twice, because the two things that read it need different guarantees.
+ //
+ // This one exists before this project is compiled, so that a call to a tag the project itself
+ // declares can be resolved as it compiles. It is read from source, so it cannot describe
+ // everything: a tag library referring to a type written in another language, or generated by
+ // the build, is left out, and what was missed is recorded so that nothing in an incompletely
+ // described namespace is reported as a misspelling. It is never packaged - a consumer must not
+ // be given a partial description - and pages are not compiled against it either.
+ // Everything both indexes must agree on is configured once, by type. Configuring the two
+ // tasks separately would let them describe different sets of tag libraries, and the one that
+ // is published is not the one this project compiles against - so they would diverge silently.
+ // A project keeping tag libraries elsewhere adds them the same way.
+ tasks.withType(GenerateTagLibraryIndexTask).configureEach { GenerateTagLibraryIndexTask index ->
+ index.sourceDirectories.from(project.layout.projectDirectory.dir('grails-app/taglib'))
+ index.parameterNamesRetained.set(resolvePreserveParameterNames(project))
+ index.strictTags.set(resolveStrictTags(project))
+ index.unqualifiedTagCalls.set(resolveUnqualifiedTagCalls(project))
+ index.dynamicTagNamespaces.set(resolveDynamicTagNamespaces(project))
+ index.javaLauncher.convention(launcher)
+ // The generator reads the same sources the compiler will, so it has to decode them the
+ // same way. Left to its own default it would read UTF-8 whatever the project compiles
+ // with, and a tag or namespace containing a non-ASCII character would be misread - which
+ // degrades to dynamic dispatch rather than to an error, so it would not be noticed.
+ index.sourceEncoding.convention(resolveCompileEncoding(project))
+ }
+
+ // The settings live apart from the descriptors. The descriptors are published; the settings
+ // say how this project is compiled and must reach no one else, and a directory on the runtime
+ // classpath is copied wholesale into an executable archive, where excluding a file from an
+ // archive task cannot reach it.
+ Provider settingsDir = project.layout.buildDirectory.dir('generated/grails-taglib-settings')
+ Provider tagLibIndexDir = project.layout.buildDirectory.dir('generated/grails-taglibs')
+ def generateTagLibraryIndex = tasks.register('generateTagLibraryIndex', GenerateTagLibraryIndexTask) {
+ it.destinationDirectory.set(tagLibIndexDir)
+ it.settingsDirectory.set(settingsDir)
+ it.generatorClasspath.from(project.configurations.named('compileClasspath'))
+ // A tag library referring to a service, base class or trait of this project needs that
+ // source to be read, not guessed, or it would be described wrongly or not at all.
+ it.resolutionSourceRoots.from(project.provider { resolveGroovySourceRoots(mainSourceSet) })
+ }
+ FileCollection tagLibIndex = project.files(tagLibIndexDir).builtBy(generateTagLibraryIndex)
+
+ // And this one is written again once the project has been compiled, with its own classes on
+ // the classpath, where every tag library resolves whatever language it was written in. It is
+ // the authoritative index: the one pages are compiled against, the one packaged, and the one a
+ // project depending on this one reads. Every run replaces the directory, so a renamed or
+ // deleted tag library cannot survive in it.
+ Provider packagedIndexDir =
+ project.layout.buildDirectory.dir('generated/grails-taglibs-packaged')
+ Provider packagedSettingsDir =
+ project.layout.buildDirectory.dir('generated/grails-taglib-settings-packaged')
+ def packageTagLibraryIndex = tasks.register('packageTagLibraryIndex', GenerateTagLibraryIndexTask) {
+ it.description = 'Regenerates the tag library index against the compiled project'
+ it.destinationDirectory.set(packagedIndexDir)
+ it.settingsDirectory.set(packagedSettingsDir)
+ // The compiled classes, and a dependency on the task that gathers them, so this waits
+ // for everything that writes into those directories rather than for the compile tasks
+ // alone - the ast classes are copied in after compiling, for one.
+ //
+ // Deliberately the class directories and not the whole source set output. A view compiler
+ // registers its own output directory into that output and runs after the classes task, so
+ // it cannot declare the classes task as its producer without a cycle, and anything reading
+ // the whole output is left consuming a directory nothing says it produced. Compiled views
+ // are no use in resolving what a tag library declares anyway.
+ it.generatorClasspath.from(project.configurations.named('compileClasspath'), classesDirs)
+ it.dependsOn(tasks.named('classes'))
+ }
+ FileCollection packagedSettings =
+ project.files(packagedSettingsDir).builtBy(packageTagLibraryIndex)
+ FileCollection packagedTagLibIndex =
+ project.files(packagedIndexDir).builtBy(packageTagLibraryIndex)
+
+ // Pages resolve tag calls against the index and are compiled in a process of their own, so the
+ // authoritative index has to be on their classpath.
FileCollection allClasspath = project.getObjects().fileCollection().from(
[
project.configurations.named('compileClasspath'),
classesDirs,
+ packagedTagLibIndex,
+ packagedSettings,
project.configurations.findByName('providedCompile') ?: null
].findAll { it }
)
- // The Java the rest of the project is built with, so that pages are built with it too.
- // Absent a toolchain this resolves to the JVM running Gradle, which is what compiling
- // pages fell back to before and remains the right answer when nothing else was asked for.
- JavaPluginExtension javaExtension = project.extensions.getByType(JavaPluginExtension)
- JavaToolchainService toolchains = project.extensions.getByType(JavaToolchainService)
- Provider launcher = toolchains.launcherFor(javaExtension.toolchain)
+ // Carried into the artifact and onto the runtime classpath directly rather than through
+ // processResources, which the classes task waits for - and this waits for the classes task.
+ if (mainSourceSet != null) {
+ mainSourceSet.runtimeClasspath = mainSourceSet.runtimeClasspath.plus(packagedTagLibIndex)
+ }
+
+ // A test renders pages too, and a test source set's runtime classpath is built from the main
+ // source set's output rather than from its runtime classpath, so it does not inherit the line
+ // above. Without this a page rendered from a test resolves its tags against an index missing
+ // the application's own tag libraries - which is where a tag resolution problem would most
+ // likely be noticed.
+ addPackagedIndexToTestRuntime(project, packagedTagLibIndex)
+
+ // Compiling this project's own controllers and tag libraries has to see the index too, or a
+ // call to a tag the same project declares cannot be resolved. The directory joins the compile
+ // classpath rather than the source set output, which would make the index wait for the
+ // compilation it exists to precede.
+ FileCollection tagLibSettings = project.files(settingsDir).builtBy(generateTagLibraryIndex)
+ tasks.named('compileGroovy', GroovyCompile).configure { GroovyCompile compile ->
+ compile.classpath = compile.classpath.plus(tagLibIndex).plus(tagLibSettings)
+ }
+
+ // The library artifact alone. A war or an executable archive is built from the runtime
+ // classpath, which already carries the descriptors into the place that archive puts classes;
+ // adding them here as well would put a second copy at the archive root, where nothing reads it
+ // and where it would disagree with the first as soon as one was rebuilt. A plain jar is not
+ // built from the runtime classpath, so it is the one that needs them added.
+ tasks.named('jar', Jar).configure { Jar archive ->
+ archive.from(packagedTagLibIndex)
+ }
def compileGroovyPages = tasks.register('compileGroovyPages', GroovyPageForkCompileTask) {
it.destinationDirectory.set(destDir)
@@ -100,7 +314,9 @@ class GroovyPagePlugin implements Plugin {
compileGroovyPages.configure {
it.dependsOn(
tasks.named('classes'),
- compileWebappGroovyPages
+ compileWebappGroovyPages,
+ // Pages resolve tag calls against the index, so it has to be written first.
+ generateTagLibraryIndex
)
}
diff --git a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/views/gsp/TagLibraryIndexFiles.groovy b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/views/gsp/TagLibraryIndexFiles.groovy
new file mode 100644
index 00000000000..d8c5373461e
--- /dev/null
+++ b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/views/gsp/TagLibraryIndexFiles.groovy
@@ -0,0 +1,133 @@
+/*
+ * 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.gradle.plugin.views.gsp
+
+import java.nio.charset.StandardCharsets
+
+import groovy.transform.CompileStatic
+
+/**
+ * The files the tag library index is made of, as the build writes them.
+ *
+ * Written here rather than by the forked generator because they say what the build asked for
+ * rather than what the sources declare, and because they have to be written even for a project with
+ * no tag libraries of its own.
+ *
+ * @since 8.0.0
+ */
+@CompileStatic
+final class TagLibraryIndexFiles {
+
+ /**
+ * Directory holding one descriptor per compiled tag library.
+ *
+ *
These restate the format {@code org.grails.taglib.index.TagLibraryIndex} owns. They cannot
+ * be shared with it: the generator is forked against the project's compile classpath precisely
+ * because this plugin does not have the framework on its own, so the constants there are not
+ * reachable from here. {@code TagLibraryIndexFilesSpec} asserts the two agree, so a rename on
+ * either side fails a test rather than quietly producing an index nothing reads.
+ *
+ *
Held without a trailing separator; {@code TagLibraryIndex.INDEX_LOCATION} carries one
+ * because it resolves classpath resources by concatenation, where this resolves files.
+ */
+ static final String INDEX_LOCATION = 'META-INF/grails/taglibs'
+
+ /**
+ * Where the settings for this compilation are written, the file part of
+ * {@code TagLibraryIndex.SETTINGS_LOCATION}.
+ */
+ static final String SETTINGS_FILE = 'compile-settings.properties'
+
+ /**
+ * The keys the settings file is written with, matching {@code TagLibraryIndex}.
+ */
+ static final String STRICT_KEY = 'strictTags'
+
+ static final String DYNAMIC_NAMESPACES_KEY = 'dynamicTagNamespaces'
+
+ static final String UNQUALIFIED_KEY = 'unqualifiedTagCalls'
+
+ static final String LOCAL_NAMESPACES_KEY = 'localNamespaces'
+
+ private TagLibraryIndexFiles() {
+ }
+
+ /**
+ * Reads the namespaces of the descriptors beneath a directory.
+ *
+ *
Taken from what was generated rather than from the sources, so that a tag library the
+ * generator could not read does not have its namespace counted as one this project describes.
+ *
+ * @param destination the directory the index was written beneath
+ * @return the namespaces described there
+ */
+ static Set readNamespaces(File destination) {
+ Set namespaces = new TreeSet<>()
+ new File(destination, INDEX_LOCATION).listFiles()?.each { File file ->
+ if (!file.isFile() || !file.name.endsWith('.properties') || file.name == SETTINGS_FILE) {
+ return
+ }
+ Properties descriptor = new Properties()
+ file.withInputStream { descriptor.load(it) }
+ String namespace = descriptor.getProperty('namespace')
+ if (namespace) {
+ namespaces.add(namespace)
+ }
+ }
+ namespaces
+ }
+
+ /**
+ * Removes descriptors left by an earlier run, for a project that no longer declares any tag
+ * library. Without it the index would keep describing tags that no longer exist.
+ *
+ * @param destination the directory the index is written beneath
+ */
+ static void clearIndex(File destination) {
+ File indexDirectory = new File(destination, INDEX_LOCATION)
+ indexDirectory.listFiles()?.each { File file ->
+ if (file.isFile() && file.name.endsWith('.properties') && file.name != SETTINGS_FILE) {
+ file.delete()
+ }
+ }
+ }
+
+ /**
+ * Records what the build asked for, so that the compiler reads it as an ordinary classpath
+ * resource and Gradle sees it as an output of a task with declared inputs.
+ *
+ * @param destination the directory the index is written beneath
+ * @param strictTags whether an unknown tag fails compilation
+ * @param dynamicNamespaces namespaces filled in while the application runs
+ * @param unqualifiedTagCalls whether a call written without a namespace may be compiled
+ * @param localNamespaces the namespaces this project's own tag libraries declare
+ */
+ static void writeSettings(File destination, boolean strictTags, Set dynamicNamespaces,
+ boolean unqualifiedTagCalls = false, Set localNamespaces = [] as Set) {
+ File indexDirectory = new File(destination, INDEX_LOCATION)
+ indexDirectory.mkdirs()
+ // Written by hand rather than through Properties.store, which stamps the current time into a
+ // comment and would make the output differ between otherwise identical builds.
+ String text = "${DYNAMIC_NAMESPACES_KEY}=${new TreeSet(dynamicNamespaces).join(',')}\n" +
+ "${STRICT_KEY}=${strictTags}\n" +
+ "${UNQUALIFIED_KEY}=${unqualifiedTagCalls}\n" +
+ "${LOCAL_NAMESPACES_KEY}=${new TreeSet(localNamespaces).join(',')}\n"
+ new File(indexDirectory, SETTINGS_FILE).setText(text, StandardCharsets.UTF_8.name())
+ }
+}
diff --git a/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/views/gsp/GenerateTagLibraryIndexTaskSpec.groovy b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/views/gsp/GenerateTagLibraryIndexTaskSpec.groovy
new file mode 100644
index 00000000000..f4bfe8f9c00
--- /dev/null
+++ b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/views/gsp/GenerateTagLibraryIndexTaskSpec.groovy
@@ -0,0 +1,231 @@
+/*
+ * 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.gradle.plugin.views.gsp
+
+import java.nio.file.Path
+
+import org.gradle.api.Project
+import org.gradle.api.Task
+import org.gradle.api.tasks.SourceSet
+import org.gradle.api.tasks.SourceSetContainer
+import org.gradle.testfixtures.ProjectBuilder
+import spock.lang.Specification
+import spock.lang.TempDir
+
+import org.grails.gradle.plugin.core.GrailsExtension
+
+/**
+ * The index has to be generated before anything that resolves tag calls is compiled, and has to travel
+ * with the artifact so that a project depending on this one can resolve its tags too. Both are
+ * properties of how the task is wired rather than of what it writes.
+ */
+class GenerateTagLibraryIndexTaskSpec extends Specification {
+
+ @TempDir
+ Path projectDir
+
+ Project project
+
+ def setup() {
+ // The task runs whether or not a project declares tag libraries of its own, because it also
+ // records what the build declared about the tag libraries it uses. A tag library is present
+ // here so that the ordinary case is what most of these check.
+ File taglibDir = new File(projectDir.toFile(), 'grails-app/taglib/demo')
+ taglibDir.mkdirs()
+ new File(taglibDir, 'DemoTagLib.groovy').text = '''
+ package demo
+ class DemoTagLib {
+ static namespace = 'demo'
+ def hello(Map attrs) { }
+ }
+ '''
+ project = ProjectBuilder.builder().withProjectDir(projectDir.toFile()).build()
+ project.pluginManager.apply('groovy')
+ project.pluginManager.apply(GroovyPagePlugin)
+ }
+
+ void 'the task is registered'() {
+ expect:
+ project.tasks.findByName('generateTagLibraryIndex') instanceof GenerateTagLibraryIndexTask
+ }
+
+ void 'it reads the tag library source directory and writes into the build directory'() {
+ given:
+ GenerateTagLibraryIndexTask task = project.tasks.getByName('generateTagLibraryIndex') as GenerateTagLibraryIndexTask
+
+ expect:
+ task.sourceDirectories.files*.canonicalFile ==
+ [new File(projectDir.toFile(), 'grails-app/taglib').canonicalFile]
+ task.destinationDirectory.get().asFile.canonicalFile ==
+ new File(projectDir.toFile(), 'build/generated/grails-taglibs').canonicalFile
+ }
+
+ void 'page compilation runs after the index exists'() {
+ expect: 'pages resolve tag calls against the index, so it has to be written first'
+ dependencyNames(project.tasks.getByName('compileGroovyPages')).contains('generateTagLibraryIndex')
+ }
+
+ void 'compiling this project sees the index it generates'() {
+ given: 'otherwise a call to a tag this project declares could not be resolved as it compiles'
+ Task compileGroovy = project.tasks.getByName('compileGroovy')
+
+ expect:
+ dependencyNames(compileGroovy).contains('generateTagLibraryIndex')
+
+ and: 'the index is on the compile classpath, not merely produced alongside it'
+ compileGroovy.classpath.files*.canonicalFile.contains(
+ new File(projectDir.toFile(), 'build/generated/grails-taglibs').canonicalFile)
+ }
+
+ void 'the generator does not wait for this project to be compiled'() {
+ given: 'it reads source, so requiring compiled output would invert the ordering it exists for'
+ Task generate = project.tasks.getByName('generateTagLibraryIndex')
+
+ expect:
+ !dependencyNames(generate).contains('classes')
+ !dependencyNames(generate).contains('compileGroovy')
+ }
+
+ void 'the index written before compilation is not packaged'() {
+ given: 'read from source, it cannot describe a tag library it cannot resolve yet'
+ SourceSet main = (project.extensions.getByType(SourceSetContainer)).getByName('main')
+
+ expect: 'so a project depending on this one must not be given it'
+ !main.resources.srcDirs*.canonicalFile.contains(
+ new File(projectDir.toFile(), 'build/generated/grails-taglibs').canonicalFile)
+ }
+
+ void 'the index written after compilation is on the runtime classpath'() {
+ given: 'a page compiled while the application runs resolves its tags against it'
+ SourceSet main = (project.extensions.getByType(SourceSetContainer)).getByName('main')
+
+ expect:
+ main.runtimeClasspath.files*.canonicalFile.contains(
+ new File(projectDir.toFile(), 'build/generated/grails-taglibs-packaged').canonicalFile)
+ }
+
+ void 'the index written after compilation waits for everything that writes the class output'() {
+ given: 'not for the compile tasks alone, which others may write into that directory after'
+ Task packageIndex = project.tasks.getByName('packageTagLibraryIndex')
+
+ expect:
+ dependencyNames(packageIndex).contains('classes')
+ }
+
+ void 'compiling pages sees the authoritative index'() {
+ given: 'a page must see every tag, including one only describable once compiled'
+ Task compilePages = project.tasks.getByName('compileGroovyPages')
+
+ expect:
+ compilePages.classpath.files*.canonicalFile.contains(
+ new File(projectDir.toFile(), 'build/generated/grails-taglibs-packaged').canonicalFile)
+
+ and: 'and not the partial one written before compilation'
+ !compilePages.classpath.files*.canonicalFile.contains(
+ new File(projectDir.toFile(), 'build/generated/grails-taglibs').canonicalFile)
+ }
+
+ void 'the strictness and dynamic namespaces the build declares are task inputs'() {
+ given: 'the settings are read when the task runs, so declaring them later still reaches it'
+ GenerateTagLibraryIndexTask task = project.tasks.getByName('generateTagLibraryIndex') as GenerateTagLibraryIndexTask
+ GrailsExtension grails = project.extensions.create('grails', GrailsExtension, project)
+
+ when:
+ grails.compileStatic.strictTags.set(true)
+ grails.compileStatic.dynamicTagNamespaces.set(['legacy'] as Set)
+
+ then: 'read from the build rather than from a system property, so a change recompiles'
+ task.strictTags.get()
+ task.dynamicTagNamespaces.get() == ['legacy'] as Set
+ }
+
+ void 'a project with no tag libraries of its own still records what the build declared'() {
+ given: 'the settings apply to compiling the project, whether or not it declares tag libraries'
+ File emptyDir = File.createTempDir('no-taglibs', '')
+ Project empty = ProjectBuilder.builder().withProjectDir(emptyDir).build()
+ empty.pluginManager.apply('groovy')
+ empty.pluginManager.apply(GroovyPagePlugin)
+ GrailsExtension grails = empty.extensions.create('grails', GrailsExtension, empty)
+ grails.compileStatic.dynamicTagNamespaces.set(['legacy'] as Set)
+ GenerateTagLibraryIndexTask task =
+ empty.tasks.getByName('generateTagLibraryIndex') as GenerateTagLibraryIndexTask
+
+ when:
+ task.generate()
+
+ then: 'written apart from the descriptors, so it can never travel with them'
+ File settings = new File(emptyDir,
+ 'build/generated/grails-taglib-settings/META-INF/grails/taglibs/compile-settings.properties')
+ settings.isFile()
+ settings.text.contains('dynamicTagNamespaces=legacy')
+ settings.text.contains('strictTags=false')
+
+ cleanup:
+ emptyDir.deleteDir()
+ }
+
+ void 'a build that declares nothing is left as permissive as before'() {
+ given:
+ GenerateTagLibraryIndexTask task = project.tasks.getByName('generateTagLibraryIndex') as GenerateTagLibraryIndexTask
+
+ expect:
+ !task.strictTags.get()
+ task.dynamicTagNamespaces.get().isEmpty()
+ }
+
+ void 'the index is generated with the java the project is built with'() {
+ given: 'it runs against the project compile classpath, so it needs the java that built it'
+ GenerateTagLibraryIndexTask task = project.tasks.getByName('generateTagLibraryIndex') as GenerateTagLibraryIndexTask
+
+ expect:
+ task.javaLauncher.present
+ }
+
+ private static Set dependencyNames(Task task) {
+ task.taskDependencies.getDependencies(task)*.name as Set
+ }
+
+ void 'further tag library source directories can be added'() {
+ given: 'a project keeping tag libraries outside grails-app/taglib as well'
+ GenerateTagLibraryIndexTask task = project.tasks.getByName('generateTagLibraryIndex') as GenerateTagLibraryIndexTask
+ File extra = new File(projectDir.toFile(), 'src/main/groovy')
+
+ when:
+ task.sourceDirectories.from(extra)
+
+ then: 'both are scanned, so tags declared in either resolve in the same compilation'
+ task.sourceDirectories.files*.canonicalFile.contains(extra.canonicalFile)
+ task.sourceDirectories.files.size() == 2
+ }
+
+ void 'the task declares its inputs and outputs so it can be skipped and cached'() {
+ given:
+ Task task = project.tasks.getByName('generateTagLibraryIndex')
+
+ expect: 'a declared output directory, without which stale entries could never be detected'
+ !task.outputs.files.isEmpty()
+
+ and: 'declared inputs, so an unchanged source set does not regenerate'
+ !task.inputs.files.isEmpty()
+
+ and: 'and it is cacheable'
+ task.class.superclass.isAnnotationPresent(org.gradle.api.tasks.CacheableTask) ||
+ task.class.isAnnotationPresent(org.gradle.api.tasks.CacheableTask)
+ }
+}
diff --git a/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/views/gsp/TagLibraryIndexFilesSpec.groovy b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/views/gsp/TagLibraryIndexFilesSpec.groovy
new file mode 100644
index 00000000000..57a18131484
--- /dev/null
+++ b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/views/gsp/TagLibraryIndexFilesSpec.groovy
@@ -0,0 +1,113 @@
+/*
+ * 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.gradle.plugin.views.gsp
+
+import java.nio.file.Files
+import java.nio.file.Path
+
+import spock.lang.Specification
+import spock.lang.TempDir
+
+/**
+ * The on-disk format is owned by {@code org.grails.taglib.index.TagLibraryIndex}, which this plugin
+ * cannot reference: the generator is forked against the project's compile classpath precisely because
+ * the framework is not on the plugin's own.
+ *
+ * So the format is restated here, and pinned here. The framework side pins the same strings in
+ * {@code TagLibraryIndexSpec}, so renaming either without the other fails a test rather than quietly
+ * writing an index that nothing reads.
+ */
+class TagLibraryIndexFilesSpec extends Specification {
+
+ @TempDir
+ Path tempDir
+
+ void 'the descriptor directory is the one the framework reads'() {
+ expect: 'TagLibraryIndex.INDEX_LOCATION, without the trailing separator it uses for resources'
+ TagLibraryIndexFiles.INDEX_LOCATION == 'META-INF/grails/taglibs'
+ }
+
+ void 'the settings file is the one the framework reads'() {
+ expect: 'the file part of TagLibraryIndex.SETTINGS_LOCATION'
+ TagLibraryIndexFiles.SETTINGS_FILE == 'compile-settings.properties'
+ }
+
+ void 'the settings keys are the ones the framework reads'() {
+ expect:
+ TagLibraryIndexFiles.STRICT_KEY == 'strictTags'
+ TagLibraryIndexFiles.DYNAMIC_NAMESPACES_KEY == 'dynamicTagNamespaces'
+ TagLibraryIndexFiles.UNQUALIFIED_KEY == 'unqualifiedTagCalls'
+ TagLibraryIndexFiles.LOCAL_NAMESPACES_KEY == 'localNamespaces'
+ }
+
+ void 'unqualified tag calls default to off when the build says nothing'() {
+ given:
+ File destination = Files.createDirectory(tempDir.resolve('default')).toFile()
+
+ when:
+ TagLibraryIndexFiles.writeSettings(destination, false, [] as Set)
+
+ then: 'a bare name is left to dispatch as it always did unless a build opts in'
+ new File(destination, 'META-INF/grails/taglibs/compile-settings.properties').text
+ .contains('unqualifiedTagCalls=false')
+ }
+
+ void 'settings are written under those keys, sorted, without a timestamp'() {
+ given:
+ File destination = Files.createDirectory(tempDir.resolve('out')).toFile()
+
+ when:
+ TagLibraryIndexFiles.writeSettings(destination, true, ['zeta', 'alpha'] as Set, true,
+ ['mine', 'also'] as Set)
+
+ then: 'sorted so that two otherwise identical builds produce identical output'
+ new File(destination, 'META-INF/grails/taglibs/compile-settings.properties').text ==
+ 'dynamicTagNamespaces=alpha,zeta\nstrictTags=true\nunqualifiedTagCalls=true\n' +
+ 'localNamespaces=also,mine\n'
+ }
+
+ void 'the namespaces read back are the ones the descriptors declare'() {
+ given:
+ File destination = Files.createDirectory(tempDir.resolve('ns')).toFile()
+ File indexDir = new File(destination, 'META-INF/grails/taglibs')
+ indexDir.mkdirs()
+ new File(indexDir, 'demo.OneTagLib.properties').text = 'class=demo.OneTagLib\nnamespace=mine\n'
+ new File(indexDir, 'demo.TwoTagLib.properties').text = 'class=demo.TwoTagLib\nnamespace=other\n'
+ TagLibraryIndexFiles.writeSettings(destination, false, [] as Set)
+
+ expect: 'taken from what was generated, so a tag library that could not be read is not counted'
+ TagLibraryIndexFiles.readNamespaces(destination) == ['mine', 'other'] as Set
+ }
+
+ void 'clearing removes descriptors but keeps the settings beside them'() {
+ given:
+ File destination = Files.createDirectory(tempDir.resolve('clear')).toFile()
+ File indexDir = new File(destination, 'META-INF/grails/taglibs')
+ indexDir.mkdirs()
+ new File(indexDir, 'demo.OldTagLib.properties').text = 'class=demo.OldTagLib\n'
+ TagLibraryIndexFiles.writeSettings(destination, false, [] as Set)
+
+ when: 'a project that no longer declares the tag library is rebuilt'
+ TagLibraryIndexFiles.clearIndex(destination)
+
+ then: 'the stale descriptor is gone and the settings survive'
+ !new File(indexDir, 'demo.OldTagLib.properties').exists()
+ new File(indexDir, 'compile-settings.properties').exists()
+ }
+}
diff --git a/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/views/gsp/TagLibraryIndexWiringFunctionalSpec.groovy b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/views/gsp/TagLibraryIndexWiringFunctionalSpec.groovy
new file mode 100644
index 00000000000..83d0cdd695f
--- /dev/null
+++ b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/views/gsp/TagLibraryIndexWiringFunctionalSpec.groovy
@@ -0,0 +1,131 @@
+/*
+ * 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.gradle.plugin.views.gsp
+
+import org.grails.gradle.plugin.core.GradleSpecification
+
+/**
+ * What the tag library index is wired into, and what a project declaring no tag libraries has to do
+ * about it, which is nothing: the generator runs in a forked process against the project's own compile
+ * classpath, so a project with no tag libraries to describe must not fork it at all.
+ *
+ * @since 8.0
+ */
+class TagLibraryIndexWiringFunctionalSpec extends GradleSpecification {
+
+ def "the index written before compilation is used only to compile this project"() {
+ given: 'it is read from source, so it cannot describe a tag library it cannot resolve yet'
+ setupTestResourceProject('taglib-index-wiring')
+
+ when:
+ def result = executeTask('inspectTagLibraryIndexWiring')
+
+ then: 'this project resolves a call to a tag it declares as it compiles'
+ result.output.contains('COMPILE_SEES_PRE_INDEX=true')
+
+ and: 'and a partial description reaches neither a page nor a project depending on this one'
+ result.output.contains('PAGES_SEE_PRE_INDEX=false')
+ result.output.contains('PRE_INDEX_IS_A_RESOURCE=false')
+ }
+
+ def "the index written after compilation is the one packaged and compiled against"() {
+ given: 'by then every tag library resolves, whatever language its collaborators were written in'
+ setupTestResourceProject('taglib-index-wiring')
+
+ when:
+ def result = executeTask('inspectTagLibraryIndexWiring')
+
+ then:
+ result.output.contains('PAGES_SEE_PACKAGED=true')
+ result.output.contains('RUNTIME_SEES_PACKAGED=true')
+
+ and: 'it waits for everything that writes the class output, not just the compile tasks'
+ result.output.contains('PACKAGED_WAITS_FOR_CLASSES=true')
+ }
+
+ def "a test resolves tags against the same index as the application"() {
+ given: 'a test source set builds its runtime classpath from the main output, not from the ' +
+ 'main runtime classpath, so it does not inherit the index by itself'
+ setupTestResourceProject('taglib-index-wiring')
+
+ when:
+ def result = executeTask('inspectTagLibraryIndexWiring')
+
+ then: 'otherwise a page rendered by a test would resolve against an index missing the ' +
+ 'application own tag libraries, which is where a problem would most likely be seen'
+ result.output.contains('TEST_RUNTIME_SEES_PACKAGED=true')
+ }
+
+ def "the build stores and reuses a configuration cache entry"() {
+ given: 'the index tasks read the grails extension through providers, which is only sound if ' +
+ 'those values are resolved when the entry is stored rather than at execution'
+ setupTestResourceProject('taglib-index-wiring')
+
+ when: 'stored'
+ def stored = executeTask('classes', ['--configuration-cache'])
+
+ then:
+ assertTaskSuccess('generateTagLibraryIndex', stored)
+
+ when: 'and reused, which is what fails if a Project was captured and serialised'
+ def reused = executeTask('classes', ['--configuration-cache'])
+
+ then:
+ reused.output.contains('Reusing configuration cache')
+ }
+
+ def "a project with no tag libraries does not fork the generator"() {
+ given: 'the generator is only on the compile classpath of a project that has tag libraries'
+ setupTestResourceProject('taglib-index-wiring')
+
+ when: 'both index tasks run; forking either would fail for want of the generator'
+ def result = executeTask('classes')
+
+ then:
+ assertTaskSuccess('generateTagLibraryIndex', result)
+ }
+
+ def "the settings the build declares are not packaged"() {
+ given: 'they say how this project compiles, so a project depending on it must not inherit them'
+ def runner = setupTestResourceProject('taglib-index-wiring')
+
+ when:
+ def result = executeTask('jar')
+
+ then:
+ assertTaskSuccess('jar', result)
+
+ and:
+ File jar = new File(runner.projectDir, 'build/libs').listFiles().find { it.name.endsWith('.jar') }
+ new java.util.zip.ZipFile(jar).withCloseable { zip ->
+ zip.getEntry('META-INF/grails/taglibs/compile-settings.properties') == null
+ }
+ }
+
+ def "the whole build wires together without a dependency cycle"() {
+ given: 'the packaged index waits for classes, and nothing that classes waits for waits for it'
+ setupTestResourceProject('taglib-index-wiring')
+
+ when:
+ def result = executeTask('build')
+
+ then:
+ assertTaskSuccess('packageTagLibraryIndex', result)
+ }
+}
diff --git a/grails-gradle/plugins/src/test/resources/test-projects/taglib-index-wiring/build.gradle b/grails-gradle/plugins/src/test/resources/test-projects/taglib-index-wiring/build.gradle
new file mode 100644
index 00000000000..3fabdaf93b4
--- /dev/null
+++ b/grails-gradle/plugins/src/test/resources/test-projects/taglib-index-wiring/build.gradle
@@ -0,0 +1,32 @@
+// Verifies which of the two tag library indexes is which: the one written before compilation is for
+// compiling this project only, and the one written after it is the authoritative one that pages are
+// compiled against, that is packaged, and that a project depending on this one reads.
+// No tag library sources are present, so neither generator forks a process.
+plugins {
+ id 'groovy'
+ id 'org.apache.grails.gradle.grails-gsp'
+}
+
+tasks.register('inspectTagLibraryIndexWiring') {
+ def compileGroovyClasspath = tasks.named('compileGroovy').get().classpath.files.collect { path(it) }
+ def pagesClasspath = tasks.named('compileGroovyPages').get().classpath.files.collect { path(it) }
+ def resourceDirs = sourceSets.main.resources.srcDirs.collect { path(it) }
+ def runtimePaths = sourceSets.main.runtimeClasspath.files.collect { path(it) }
+ def testRuntimePaths = sourceSets.test.runtimeClasspath.files.collect { path(it) }
+ def packaged = tasks.named('packageTagLibraryIndex').get()
+ def packagedDeps = packaged.taskDependencies.getDependencies(packaged)*.name
+
+ doLast {
+ println "COMPILE_SEES_PRE_INDEX=${compileGroovyClasspath.any { it.endsWith('/generated/grails-taglibs') }}"
+ println "PAGES_SEE_PACKAGED=${pagesClasspath.any { it.endsWith('/generated/grails-taglibs-packaged') }}"
+ println "PAGES_SEE_PRE_INDEX=${pagesClasspath.any { it.endsWith('/generated/grails-taglibs') }}"
+ println "PRE_INDEX_IS_A_RESOURCE=${resourceDirs.any { it.endsWith('/generated/grails-taglibs') }}"
+ println "RUNTIME_SEES_PACKAGED=${runtimePaths.any { it.endsWith('/generated/grails-taglibs-packaged') }}"
+ println "PACKAGED_WAITS_FOR_CLASSES=${packagedDeps.contains('classes')}"
+ println "TEST_RUNTIME_SEES_PACKAGED=${testRuntimePaths.any { it.endsWith('/generated/grails-taglibs-packaged') }}"
+ }
+}
+
+static String path(File file) {
+ file.absolutePath.replace('\\', '/')
+}
diff --git a/grails-gradle/plugins/src/test/resources/test-projects/taglib-index-wiring/settings.gradle b/grails-gradle/plugins/src/test/resources/test-projects/taglib-index-wiring/settings.gradle
new file mode 100644
index 00000000000..689d33d6b1d
--- /dev/null
+++ b/grails-gradle/plugins/src/test/resources/test-projects/taglib-index-wiring/settings.gradle
@@ -0,0 +1 @@
+rootProject.name = 'taglib-index-wiring'
diff --git a/grails-gsp/core/src/main/groovy/org/grails/gsp/GroovyPage.java b/grails-gsp/core/src/main/groovy/org/grails/gsp/GroovyPage.java
index 49f3068538e..0fd56700da5 100644
--- a/grails-gsp/core/src/main/groovy/org/grails/gsp/GroovyPage.java
+++ b/grails-gsp/core/src/main/groovy/org/grails/gsp/GroovyPage.java
@@ -30,6 +30,7 @@
import groovy.lang.Binding;
import groovy.lang.Closure;
import groovy.lang.GroovyObject;
+import groovy.lang.MissingMethodException;
import groovy.lang.Script;
import org.codehaus.groovy.runtime.InvokerHelper;
@@ -49,6 +50,7 @@
import org.grails.taglib.GroovyPageAttributes;
import org.grails.taglib.TagBodyClosure;
import org.grails.taglib.TagLibraryLookup;
+import org.grails.taglib.TagLibraryMetaUtils;
import org.grails.taglib.TagMethodContext;
import org.grails.taglib.TagMethodInvoker;
import org.grails.taglib.TagOutput;
@@ -255,6 +257,19 @@ public void setGspTagLibraryLookup(TagLibraryLookup gspTagLibraryLookup) {
this.gspTagLibraryLookup = gspTagLibraryLookup;
}
+ /**
+ * The tag libraries this page can reach.
+ *
+ *
Named as the tag library invoker trait names it, so that a tag call compiled into a direct
+ * invocation reads the same whether it was written in a page, a tag library or a controller.
+ *
+ * @return the lookup, or {@code null} before the page has been initialised
+ * @since 8.0.0
+ */
+ public TagLibraryLookup getTagLibraryLookup() {
+ return this.gspTagLibraryLookup;
+ }
+
/**
* Obtains a reference to the JSP tag library resolver instance
*
@@ -296,6 +311,38 @@ public Object getProperty(String property) {
return resolveProperty(property);
}
+ /**
+ * Resolves a tag called without a namespace, as {@code ${message(code: 'x')}} is.
+ *
+ *
A real method rather than one installed onto this page's metaclass. Installing it, along with
+ * a method for every tag and a property for every namespace, meant writing to an
+ * ExpandoMetaClass for every page compiled and made every later tag call a read of an initialised
+ * metaclass, which is guarded by a lock.
+ *
+ * @param name the tag name
+ * @param args the arguments the tag was called with
+ * @return whatever the tag produces
+ * @throws MissingMethodException when there is no tag library lookup to resolve the name against
+ */
+ public Object methodMissing(String name, Object args) {
+ if (gspTagLibraryLookup == null) {
+ // Without a lookup there is nothing to resolve the name against, which is a missing
+ // method. Dispatching anyway arrives at the same answer, but only because a dynamic call
+ // on a null receiver happens to yield no tag library rather than because anything says
+ // so; this states the contract for a field documented as null before initialisation.
+ throw new MissingMethodException(name, getClass(), makeArgumentArray(args));
+ }
+ return TagLibraryMetaUtils.methodMissingForTagLib(getMetaClass(), getClass(), gspTagLibraryLookup,
+ DEFAULT_NAMESPACE, name, args, false);
+ }
+
+ private static Object[] makeArgumentArray(Object args) {
+ if (args == null) {
+ return new Object[0];
+ }
+ return args instanceof Object[] ? (Object[]) args : new Object[] { args };
+ }
+
protected Object resolveProperty(String property) {
Object value = getBinding().getVariable(property);
if (value != null) {
diff --git a/grails-gsp/core/src/main/groovy/org/grails/gsp/GroovyPagesMetaUtils.groovy b/grails-gsp/core/src/main/groovy/org/grails/gsp/GroovyPagesMetaUtils.groovy
index 9d281bba5af..e2d591ed074 100644
--- a/grails-gsp/core/src/main/groovy/org/grails/gsp/GroovyPagesMetaUtils.groovy
+++ b/grails-gsp/core/src/main/groovy/org/grails/gsp/GroovyPagesMetaUtils.groovy
@@ -20,10 +20,8 @@ package org.grails.gsp
import groovy.transform.CompileStatic
-import grails.util.Environment
import grails.util.GrailsMetaClassUtils
import org.grails.taglib.TagLibraryLookup
-import org.grails.taglib.TagLibraryMetaUtils
@CompileStatic
class GroovyPagesMetaUtils {
@@ -32,18 +30,19 @@ class GroovyPagesMetaUtils {
registerMethodMissingForGSP(GrailsMetaClassUtils.getExpandoMetaClass(gspClass), gspTagLibraryLookup)
}
+ /**
+ * Nothing is installed onto a page's metaclass any more.
+ *
+ *
A page used to be given methodMissing, a method for each tag and a property for each
+ * namespace as it was compiled. GroovyPage declares methodMissing itself and resolves a namespace
+ * through getProperty, so the tags reachable from a page are the same without any of those writes.
+ *
+ * @param emc the page's metaclass, no longer modified
+ * @param gspTagLibraryLookup the tag libraries, resolved through at dispatch instead
+ * @deprecated Pages resolve tags without their metaclass being written to.
+ */
+ @Deprecated
static void registerMethodMissingForGSP(final MetaClass emc, final TagLibraryLookup gspTagLibraryLookup) {
- if (gspTagLibraryLookup == null) return
- final boolean addMethodsToMetaClass = !Environment.isDevelopmentMode()
-
- GroovyObject mc = (GroovyObject) emc
- synchronized(emc) {
- mc.setProperty('methodMissing', { String name, Object args ->
- TagLibraryMetaUtils.methodMissingForTagLib(emc, emc.getTheClass(), gspTagLibraryLookup, GroovyPage.DEFAULT_NAMESPACE, name, args, addMethodsToMetaClass)
- })
- }
- TagLibraryMetaUtils.registerTagMetaMethods(emc, gspTagLibraryLookup, GroovyPage.DEFAULT_NAMESPACE)
- TagLibraryMetaUtils.registerNamespaceMetaProperties(emc, gspTagLibraryLookup)
}
}
diff --git a/grails-gsp/core/src/main/groovy/org/grails/gsp/compiler/GroovyPageTypeCheckingExtension.groovy b/grails-gsp/core/src/main/groovy/org/grails/gsp/compiler/GroovyPageTypeCheckingExtension.groovy
index 7a1e3f83561..b1d0d9cbdee 100644
--- a/grails-gsp/core/src/main/groovy/org/grails/gsp/compiler/GroovyPageTypeCheckingExtension.groovy
+++ b/grails-gsp/core/src/main/groovy/org/grails/gsp/compiler/GroovyPageTypeCheckingExtension.groovy
@@ -31,6 +31,9 @@ import org.codehaus.groovy.ast.expr.VariableExpression
import org.codehaus.groovy.transform.stc.GroovyTypeCheckingExtensionSupport
import org.codehaus.groovy.transform.stc.StaticTypesMarker
+import org.grails.gsp.GroovyPage
+import org.grails.taglib.index.TagLibraryIndex
+
/**
* CompileStatic type checking extension for GSPs
*
@@ -39,6 +42,22 @@ import org.codehaus.groovy.transform.stc.StaticTypesMarker
*/
class GroovyPageTypeCheckingExtension extends GroovyTypeCheckingExtensionSupport.TypeCheckingDSL {
+ /**
+ * Tag libraries compiled ahead of this page, discovered from their compile-time descriptors.
+ *
+ * Where the {@code taglibs} directive only states which namespaces a page is permitted to use,
+ * this states which tags actually exist. That removes the need to declare namespaces by hand for
+ * tag libraries that were on the compile classpath, and turns a call to a misspelled tag from
+ * something deferred to runtime dispatch into something reported when the page is compiled.
+ *
+ * Read from the class loader compiling the page rather than from this extension's own, and cached
+ * against that loader rather than in a field here, so that one project's tag libraries are not
+ * carried into the next compilation in the same Gradle daemon.
+ */
+ private TagLibraryIndex getTagLibraryIndex() {
+ TagLibraryIndex.forClassLoader(typeCheckingVisitor?.sourceUnit?.classLoader)
+ }
+
@Override
Object run() {
ClassNode configAnnotationClassNode = ClassHelper.make(GroovyPageTypeCheckingConfig)
@@ -56,6 +75,9 @@ class GroovyPageTypeCheckingExtension extends GroovyTypeCheckingExtensionSupport
currentScope.allowedTagLibs = ListExpression.cast(taglibsExpression).expressions.collect([] as Set) { it.text.trim() }
}
}
+ // Namespaces backed by a compiled tag library need no declaration: their tags are known.
+ currentScope.allowedTagLibs.addAll(tagLibraryIndex.namespaces)
+ currentScope.allowedTagLibs.addAll(tagLibraryIndex.dynamicNamespaces)
}
unresolvedProperty { PropertyExpression pe ->
@@ -74,12 +96,19 @@ class GroovyPageTypeCheckingExtension extends GroovyTypeCheckingExtensionSupport
methodNotFound { receiver, name, argList, argTypes, call ->
if (isThisTheReceiver(call)) {
+ // An unqualified call in a page is resolved against the model the page was rendered
+ // with before it reaches a tag, and that model is not known here, so such a call is
+ // left dynamic and never judged as a tag. A call that names its namespace, and one
+ // written as markup, are checked where they are compiled.
return makeDynamic(call)
}
def objectExpression = call.objectExpression
if (objectExpression == null) {
return null
}
+ // A call naming its namespace is reported where it is rewritten, by
+ // CompiledTagCallRewriter, which sees every page rather than only a statically compiled
+ // one. Reporting it here as well would report it twice.
if (currentScope.dynamicProperties.contains(objectExpression)) {
return makeDynamic(call)
}
diff --git a/grails-gsp/core/src/test/groovy/org/grails/gsp/GroovyPageMethodMissingSpec.groovy b/grails-gsp/core/src/test/groovy/org/grails/gsp/GroovyPageMethodMissingSpec.groovy
new file mode 100644
index 00000000000..f20c6028693
--- /dev/null
+++ b/grails-gsp/core/src/test/groovy/org/grails/gsp/GroovyPageMethodMissingSpec.groovy
@@ -0,0 +1,120 @@
+/*
+ * 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.gsp
+
+import groovy.transform.CompileStatic
+import spock.lang.Specification
+
+/**
+ * A page with no tag library lookup has nothing to resolve an unqualified name against, and has to
+ * say so as a missing method.
+ *
+ *
Resolving unqualified names moved off the metaclass and onto a real {@code methodMissing}.
+ * Installing it onto the metaclass used to be skipped altogether when there was no lookup, so the
+ * page simply had no {@code methodMissing} and an unresolved call reported a missing method. A real
+ * method is always there, so the same condition has to be handled rather than reached.
+ */
+class GroovyPageMethodMissingSpec extends Specification {
+
+ void 'the page under test really has no tag library lookup'() {
+ expect: 'otherwise every case below would be exercising the resolved path'
+ lookupOf(new LookupLessPage()) == null
+ }
+
+ void 'an unresolvable call on a page with no lookup reports a missing method'() {
+ given: 'a page that was never given a tag library lookup'
+ GroovyPage page = new LookupLessPage()
+
+ when:
+ callMethodMissing(page,'noSuchTag', [[code: 'x']] as Object[])
+
+ then: 'not a null pointer from reaching through the absent lookup'
+ MissingMethodException e = thrown()
+ e.method == 'noSuchTag'
+ }
+
+ void 'the name and arguments are carried on the exception'() {
+ given:
+ GroovyPage page = new LookupLessPage()
+
+ when:
+ callMethodMissing(page,'anotherTag', ['sole'] as Object[])
+
+ then:
+ MissingMethodException e = thrown()
+ e.method == 'anotherTag'
+ e.arguments == ['sole'] as Object[]
+ }
+
+ void 'a call made with no arguments is reported the same way'() {
+ given: 'the shape a page produces for ${bareTag()}'
+ GroovyPage page = new LookupLessPage()
+
+ when:
+ callMethodMissing(page,'bareTag', [] as Object[])
+
+ then:
+ MissingMethodException e = thrown()
+ e.method == 'bareTag'
+ e.arguments.length == 0
+ }
+
+ void 'a null argument list is still a missing method rather than a null pointer'() {
+ given:
+ GroovyPage page = new LookupLessPage()
+
+ when:
+ callMethodMissing(page,'nullArgsTag', null)
+
+ then: 'what it carries matters less than that it is not an NPE'
+ MissingMethodException e = thrown()
+ e.method == 'nullArgsTag'
+ }
+
+ /**
+ * Calls the method rather than letting the metaclass route an explicit {@code methodMissing} call
+ * somewhere else, so what is exercised is the method a page's own unresolved call reaches.
+ */
+ @CompileStatic
+ private static Object callMethodMissing(GroovyPage page, String name, Object args) {
+ page.methodMissing(name, args)
+ }
+
+ /**
+ * Reads the lookup through the getter rather than as a property, since a page routes property
+ * access through its own resolution.
+ */
+ @CompileStatic
+ private static Object lookupOf(GroovyPage page) {
+ page.getTagLibraryLookup()
+ }
+
+ private static class LookupLessPage extends GroovyPage {
+
+ @Override
+ String getGroovyPageFileName() {
+ 'lookupless.gsp'
+ }
+
+ @Override
+ Object run() {
+ null
+ }
+ }
+}
diff --git a/grails-gsp/grails-taglib/src/main/groovy/org/grails/core/gsp/DefaultGrailsTagLibClass.java b/grails-gsp/grails-taglib/src/main/groovy/org/grails/core/gsp/DefaultGrailsTagLibClass.java
index 4aee3262f2b..629c4e40740 100644
--- a/grails-gsp/grails-taglib/src/main/groovy/org/grails/core/gsp/DefaultGrailsTagLibClass.java
+++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/core/gsp/DefaultGrailsTagLibClass.java
@@ -18,7 +18,6 @@
*/
package org.grails.core.gsp;
-import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.HashMap;
import java.util.HashSet;
@@ -35,6 +34,8 @@
import org.grails.core.AbstractInjectableGrailsClass;
import org.grails.core.artefact.gsp.TagLibArtefactHandler;
import org.grails.taglib.TagMethodInvoker;
+import org.grails.taglib.discovery.ReflectedTagLibraryView;
+import org.grails.taglib.discovery.TagDiscoveryRules;
/**
* Default implementation of a tag lib class.
@@ -73,21 +74,12 @@ public DefaultGrailsTagLibClass(Class> clazz) {
}
tags.addAll(TagMethodInvoker.getInvokableTagMethodNames(clazz));
- // Also scan declared fields via Java reflection to find Closure-typed tags
- // that may not be reported by the metaclass (e.g., when @CompileStatic is applied
- // at the class level, Groovy 4 may compile Closure properties differently so that
- // MetaProperty.getType() no longer reports Closure).
- for (Class> current = clazz; current != null && current != Object.class; current = current.getSuperclass()) {
- for (Field field : current.getDeclaredFields()) {
- int modifiers = field.getModifiers();
- if (Modifier.isStatic(modifiers)) {
- continue;
- }
- if (Closure.class.isAssignableFrom(field.getType())) {
- tags.add(field.getName());
- }
- }
- }
+ // Closure-typed tags are also read directly from the class, because the metaclass does not
+ // always report them as properties (with @CompileStatic at the class level, a Closure
+ // property may not be compiled as one). Read through the shared rules rather than walking
+ // the hierarchy here, so that the set a build records and the set registered here are
+ // produced by the same code and cannot describe different tags.
+ tags.addAll(TagDiscoveryRules.findTags(new ReflectedTagLibraryView(clazz)));
String ns = getStaticPropertyValue(NAMESPACE_FIELD_NAME, String.class);
if (ns != null && !"".equals(ns.trim())) {
diff --git a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/CompiledTagInvocation.java b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/CompiledTagInvocation.java
new file mode 100644
index 00000000000..e2c6317070a
--- /dev/null
+++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/CompiledTagInvocation.java
@@ -0,0 +1,157 @@
+/*
+ * 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.taglib;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import groovy.lang.Closure;
+
+import org.grails.taglib.encoder.OutputContext;
+import org.grails.taglib.encoder.OutputContextLookupHelper;
+
+/**
+ * Invokes a tag whose namespace and name are known without going through Groovy's method dispatch.
+ *
+ *
Calling a tag as {@code g.message(code: 'x')} reaches the tag library through {@code
+ * invokeMethod}, which means a dynamic call site in the caller's bytecode even when that caller is
+ * statically compiled. The tag being called is fixed in the source, so once it has been resolved
+ * against the tag library index there is nothing left to decide at runtime beyond which bean holds it.
+ *
+ *
This is the entry point such a call is expressed as: an ordinary method call taking the
+ * namespace and name as arguments. It applies the same attribute and body handling, output capture,
+ * encoding and return-object behaviour as the dynamic path, because both end at
+ * {@link TagOutput#captureTagOutput}.
+ *
+ * @since 8.0.0
+ */
+public final class CompiledTagInvocation {
+
+ private static final Object[] EMPTY_ARGUMENTS = new Object[0];
+
+ private CompiledTagInvocation() {
+ }
+
+ /**
+ * Invokes a tag with attributes and a body.
+ *
+ * @param lookup the tag libraries available to the caller
+ * @param namespace the tag library namespace
+ * @param tagName the tag name within that namespace
+ * @param attrs the tag attributes, treated as empty when {@code null}
+ * @param body the tag body as a closure or as text, or {@code null} when there is none
+ * @return whatever the tag produces, which for a tag that writes to the output is its output
+ */
+ public static Object invoke(TagLibraryLookup lookup, String namespace, String tagName,
+ Map, ?> attrs, Object body) {
+ return invoke(lookup, namespace, tagName, attrs, body,
+ OutputContextLookupHelper.lookupOutputContext());
+ }
+
+ /**
+ * Invokes a tag against a known output context, for a caller that already has one to hand.
+ *
+ * @param lookup the tag libraries available to the caller
+ * @param namespace the tag library namespace
+ * @param tagName the tag name within that namespace
+ * @param attrs the tag attributes, treated as empty when {@code null}
+ * @param body the tag body as a closure or as text, or {@code null} when there is none
+ * @param outputContext where the tag writes
+ * @return whatever the tag produces
+ */
+ public static Object invoke(TagLibraryLookup lookup, String namespace, String tagName,
+ Map, ?> attrs, Object body, OutputContext outputContext) {
+ if (lookup == null) {
+ throw new GrailsTagException("Tag [" + tagName + "] cannot be invoked without a tag library lookup");
+ }
+ Map, ?> attributes = attrs != null ? attrs : Collections.emptyMap();
+ // A body may be a closure or the text a caller wrote directly, which the dynamic path accepted
+ // through overloads that wrapped the text. Narrowing this to Closure would turn a string body
+ // into a cast failure.
+ Object tagBody = body instanceof CharSequence ? new TagOutput.ConstantClosure((CharSequence) body) : body;
+ return TagOutput.captureTagOutput(lookup, namespace, tagName, attributes, tagBody, outputContext);
+ }
+
+ /**
+ * Invokes a tag with whatever arguments the call was written with.
+ *
+ *
A tag call is written in more shapes than attributes and a body: with nothing, with a body
+ * alone, or with a single value that the tag reads under its own name. Where the shape is not
+ * evident in the source - a map held in a variable, say - the arguments are only known once they
+ * have been evaluated, which is what this takes.
+ *
+ * @param lookup the tag libraries available to the caller
+ * @param namespace the tag library namespace
+ * @param tagName the tag name within that namespace
+ * @param args the evaluated arguments, in the order they were written
+ * @return whatever the tag produces
+ */
+ public static Object invokeArguments(TagLibraryLookup lookup, String namespace, String tagName,
+ Object... args) {
+ return invokeArgumentsInContext(lookup, namespace, tagName,
+ OutputContextLookupHelper.lookupOutputContext(), args);
+ }
+
+ /**
+ * Invokes a tag with whatever arguments the call was written with, against a known output context.
+ *
+ * @param lookup the tag libraries available to the caller
+ * @param namespace the tag library namespace
+ * @param tagName the tag name within that namespace
+ * @param outputContext where the tag writes
+ * @param args the evaluated arguments, in the order they were written
+ * @return whatever the tag produces
+ */
+ public static Object invokeArgumentsInContext(TagLibraryLookup lookup, String namespace,
+ String tagName, OutputContext outputContext, Object... args) {
+ Object[] arguments = args != null ? args : EMPTY_ARGUMENTS;
+ Map, ?> attrs = Collections.emptyMap();
+ Object body = null;
+ // Deliberately the same shapes, in the same order, as the dynamic dispatch in
+ // TagLibraryMetaUtils.methodMissingForTagLib, including its treatment of argument lists that
+ // match none of them: a call that produced an empty invocation there must produce one here.
+ switch (arguments.length) {
+ case 0:
+ break;
+ case 1:
+ if (arguments[0] instanceof Map, ?> map) {
+ attrs = map;
+ }
+ else if (arguments[0] instanceof Closure || arguments[0] instanceof CharSequence) {
+ body = arguments[0];
+ }
+ else {
+ Map named = new LinkedHashMap<>(1);
+ named.put(tagName, arguments[0]);
+ attrs = named;
+ }
+ break;
+ case 2:
+ if (arguments[0] instanceof Map, ?> map) {
+ attrs = map;
+ body = arguments[1];
+ }
+ break;
+ default:
+ break;
+ }
+ return invoke(lookup, namespace, tagName, attrs, body, outputContext);
+ }
+}
diff --git a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/NamespacedTagDispatcher.groovy b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/NamespacedTagDispatcher.groovy
index 285ce9bb6f0..0446a82841b 100644
--- a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/NamespacedTagDispatcher.groovy
+++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/NamespacedTagDispatcher.groovy
@@ -21,7 +21,6 @@ package org.grails.taglib
import groovy.transform.CompileStatic
import grails.core.GrailsApplication
-import grails.util.Environment
/**
* Allows dispatching to namespaced tag libraries and is used within controllers and tag libraries
@@ -37,30 +36,20 @@ class NamespacedTagDispatcher extends GroovyObjectSupport {
protected GrailsApplication application
protected Class type
protected TagLibraryLookup lookup
- protected boolean developmentMode
NamespacedTagDispatcher(String ns, Class callingType, GrailsApplication application, TagLibraryLookup lookup) {
this.namespace = ns
this.application = application
- this.developmentMode = Environment.isDevelopmentMode()
this.lookup = lookup
this.type = callingType ?: this.getClass()
- initializeMetaClass()
- }
-
- void initializeMetaClass() {
- // use per-instance metaclass
- ExpandoMetaClass emc = new ExpandoMetaClass(getClass(), false, true)
- emc.initialize()
- setMetaClass(emc)
- registerTagMetaMethods(emc)
- }
-
- protected void registerTagMetaMethods(ExpandoMetaClass emc) {
- TagLibraryMetaUtils.registerTagMetaMethods(emc, lookup, namespace)
}
+ /**
+ * Every dispatcher used to be given its own ExpandoMetaClass carrying a method for each tag in the
+ * namespace, built and populated as the dispatcher was constructed. Tags are dispatched through
+ * the lookup instead, so no metaclass is created or written to here.
+ */
def methodMissing(String name, Object args) {
- TagLibraryMetaUtils.methodMissingForTagLib(getMetaClass(), type, lookup, namespace, name, args, !developmentMode)
+ TagLibraryMetaUtils.methodMissingForTagLib(getMetaClass(), type, lookup, namespace, name, args, false)
}
}
diff --git a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibNamespaceMethodDispatcher.groovy b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibNamespaceMethodDispatcher.groovy
index 36fa8efa5fe..989e958a980 100644
--- a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibNamespaceMethodDispatcher.groovy
+++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibNamespaceMethodDispatcher.groovy
@@ -64,6 +64,6 @@ class TagLibNamespaceMethodDispatcher {
}
private Object invokeTagMethodCall(String namespace, String name, Map attrs, Object body) {
- TagOutput.captureTagOutput(lookup, namespace, name, attrs, body, outputContext)
+ CompiledTagInvocation.invoke(lookup, namespace, name, attrs, body, outputContext)
}
}
diff --git a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibraryMetaUtils.groovy b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibraryMetaUtils.groovy
index 39109920435..ddf22268633 100644
--- a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibraryMetaUtils.groovy
+++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibraryMetaUtils.groovy
@@ -32,6 +32,19 @@ import grails.core.gsp.GrailsTagLibClass
import grails.util.GrailsClassUtils
import org.grails.taglib.encoder.OutputContextLookupHelper
+/**
+ * Installs tags onto metaclasses.
+ *
+ * Tags are resolved through {@link TagLibraryLookup} and invoked through
+ * {@link CompiledTagInvocation}, so nothing needs installing onto a metaclass to call a tag. What
+ * remains here is the dynamic dispatch that a tag library registered at runtime still relies on,
+ * reachable through {@code methodMissingForTagLib} with metaclass installation switched off.
+ *
+ *
The methods that install onto a metaclass are deprecated individually. This class is not,
+ * because {@link #methodMissingForTagLib} is how a call into a namespace no compiled tag library
+ * describes is still dispatched, and is used by the tag library invoker trait, the namespace
+ * dispatcher and a compiled page alike.
+ */
class TagLibraryMetaUtils {
private static final Log LOG = LogFactory.getLog(TagLibraryMetaUtils)
@@ -40,6 +53,7 @@ class TagLibraryMetaUtils {
private final static Object[] EMPTY_OBJECT_ARRAY = new Object[0]
@CompileStatic
+ @Deprecated(since = '8.0.0')
static void enhanceTagLibMetaClass(final GrailsTagLibClass taglib, TagLibraryLookup gspTagLibraryLookup) {
final MetaClass mc = taglib.getMetaClass()
final String namespace = taglib.namespace ?: TagOutput.DEFAULT_NAMESPACE
@@ -47,6 +61,7 @@ class TagLibraryMetaUtils {
}
@CompileStatic
+ @Deprecated(since = '8.0.0')
static void enhanceTagLibMetaClass(MetaClass mc, TagLibraryLookup gspTagLibraryLookup, String namespace) {
registerTagMethodContextMetaProperties(mc)
registerTagMetaMethods(mc, gspTagLibraryLookup, namespace)
@@ -87,6 +102,7 @@ class TagLibraryMetaUtils {
}
@CompileStatic
+ @Deprecated(since = '8.0.0')
static void registerNamespaceMetaProperties(MetaClass mc, TagLibraryLookup gspTagLibraryLookup) {
for (String ns : gspTagLibraryLookup.getAvailableNamespaces()) {
registerNamespaceMetaProperty(mc, gspTagLibraryLookup, ns)
@@ -94,6 +110,7 @@ class TagLibraryMetaUtils {
}
@CompileStatic
+ @Deprecated(since = '8.0.0')
static void registerNamespaceMetaProperty(MetaClass metaClass, TagLibraryLookup gspTagLibraryLookup, String namespace) {
if (!doesMethodExist(metaClass, GrailsClassUtils.getGetterName(namespace), [] as Class[], false, true)) {
registerPropertyMissingForTag(metaClass, namespace, gspTagLibraryLookup.lookupNamespaceDispatcher(namespace))
@@ -101,6 +118,7 @@ class TagLibraryMetaUtils {
}
@CompileStatic
+ @Deprecated(since = '8.0.0')
static registerMethodMissingForTags(MetaClass metaClass, TagLibraryLookup gspTagLibraryLookup, String namespace, String name, boolean addAll = true, boolean overrideMethods = true) {
GroovyObject mc = (GroovyObject) metaClass
@@ -154,6 +172,7 @@ class TagLibraryMetaUtils {
return output
}
+ @Deprecated(since = '8.0.0')
static registerMethodMissingForTags(MetaClass mc, ApplicationContext ctx,
GrailsTagLibClass tagLibraryClass, String name) {
TagLibraryLookup gspTagLibraryLookup = ctx.getBean('gspTagLibraryLookup')
@@ -162,12 +181,14 @@ class TagLibraryMetaUtils {
}
@CompileStatic
+ @Deprecated(since = '8.0.0')
static void registerPropertyMissingForTag(MetaClass metaClass, String name, Object result) {
GroovyObject mc = (GroovyObject) metaClass
mc.setProperty(GrailsClassUtils.getGetterName(name)) { -> result }
}
@CompileStatic
+ @Deprecated(since = '8.0.0')
static void registerTagMetaMethods(MetaClass emc, TagLibraryLookup lookup, String namespace, boolean overrideMethods = true) {
for (String tagName : lookup.getAvailableTags(namespace)) {
boolean addAll = !(namespace == TagOutput.DEFAULT_NAMESPACE && tagName == 'hasErrors')
@@ -199,6 +220,30 @@ class TagLibraryMetaUtils {
existingMethod instanceof CachedMethod
}
+ /**
+ * Whether an argument list is one a tag can be called with.
+ *
+ *
A tag takes attributes, a body, or both, which is none, one, or two arguments whose first is
+ * a Map. Anything else the switch below reduces to a call with no attributes and no body, silently
+ * dropping what was written - so a name that is both a tag and an ordinary overload, a tag
+ * {@code foo(Map)} beside a helper {@code foo(String, String)}, would run the tag with nothing.
+ * Such a call is left to the method lookup further down, which finds the overload.
+ *
+ * @param args the arguments the call was made with
+ * @return true when the call can be treated as a tag invocation
+ */
+ private static boolean matchesTagShape(Object[] args) {
+ switch (args.length) {
+ case 0:
+ case 1:
+ return true
+ case 2:
+ return args[0] instanceof Map
+ default:
+ return false
+ }
+ }
+
private static Object[] makeObjectArray(Object args) {
args instanceof Object[] ? (Object[]) args : [args] as Object[]
}
@@ -209,7 +254,8 @@ class TagLibraryMetaUtils {
final GroovyObject tagBean = gspTagLibraryLookup.lookupTagLibrary(namespace, name)
if (tagBean != null) {
Object tagLibProp = TagMethodInvoker.getClosureTagProperty(tagBean, name)
- if (tagLibProp instanceof Closure || TagMethodInvoker.hasInvokableTagMethod(tagBean, name)) {
+ if ((tagLibProp instanceof Closure || TagMethodInvoker.hasInvokableTagMethod(tagBean, name)) &&
+ matchesTagShape(args)) {
Map attrs = [:]
Object body = null
switch (args.length) {
@@ -251,6 +297,7 @@ class TagLibraryMetaUtils {
throw new MissingMethodException(name, type, args)
}
+ @Deprecated(since = '8.0.0')
static addTagLibMethodToMetaClass(final GroovyObject tagBean, final MetaMethod method, final MetaClass mc) {
Class[] paramTypes = method.nativeParameterTypes
Closure methodMissingClosure = null
diff --git a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodInvoker.java b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodInvoker.java
index 70e5613e3cd..a649dc7abdd 100644
--- a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodInvoker.java
+++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodInvoker.java
@@ -36,36 +36,21 @@
import groovy.lang.GroovyObject;
import groovy.lang.MissingMethodException;
-import grails.gsp.NotATag;
-import grails.gsp.Tag;
+import org.grails.taglib.discovery.ReflectedTagMethodView;
+import org.grails.taglib.discovery.TagDiscoveryRules;
public final class TagMethodInvoker {
/**
- * Method names from framework traits, Spring lifecycle interfaces, and the like
- * that must never be treated as tag methods regardless of the declaring class.
+ * Names that live on every tag library through the framework traits and are therefore never tags.
+ *
+ * Exposed so that the compile-time tag library index derives the same tag names from the AST that
+ * this class derives by reflection at runtime. A name recorded in the index but rejected here
+ * would resolve when a GSP is compiled and then fail to dispatch when it renders.
+ *
+ * @since 8.0.0
*/
- private static final Set FRAMEWORK_METHOD_NAMES = Set.of(
- "afterPropertiesSet",
- "currentRequestAttributes",
- "destroy",
- "initializeTagLibrary",
- "onApplicationEvent",
- "raw",
- "throwTagError",
- "withCodec"
- );
-
- private static final Set OBJECT_METHOD_SIGNATURES = collectSignatures(Object.class);
- private static final Set GROOVY_OBJECT_METHOD_SIGNATURES = collectSignatures(GroovyObject.class);
-
- private static Set collectSignatures(Class> type) {
- Set signatures = new HashSet<>();
- for (Method method : type.getMethods()) {
- signatures.add(signature(method));
- }
- return Collections.unmodifiableSet(signatures);
- }
+ public static final Set FRAMEWORK_METHOD_NAMES = TagDiscoveryRules.getFrameworkMethodNames();
private static final ClassValue