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> CLOSURE_FIELDS_BY_NAME = new ClassValue<>() { @Override @@ -97,16 +82,16 @@ protected Map computeValue(Class type) { } }; - private static final ClassValue>> INVOKABLE_METHODS_BY_NAME = new ClassValue<>() { + private static final ClassValue>> INVOKABLE_METHODS_BY_NAME = new ClassValue<>() { @Override - protected Map> computeValue(Class type) { + protected Map> computeValue(Class type) { Map> methodsByName = new HashMap<>(); for (Method method : type.getDeclaredMethods()) { if (isTagMethodCandidate(method)) { methodsByName.computeIfAbsent(method.getName(), ignored -> new ArrayList<>()).add(method); } } - Map> immutableMethodsByName = new HashMap<>(methodsByName.size()); + Map> immutableMethodsByName = new HashMap<>(methodsByName.size()); for (Map.Entry> entry : methodsByName.entrySet()) { // Sort methods by descending parameter count so that (Map, Closure) signatures // are tried before (Map) signatures, preventing infinite recursion when a @@ -118,12 +103,103 @@ protected Map> computeValue(Class type) { int byArity = Integer.compare(b.getParameterCount(), a.getParameterCount()); return byArity != 0 ? byArity : signature(a).compareTo(signature(b)); }); - immutableMethodsByName.put(entry.getKey(), Collections.unmodifiableList(sorted)); + List bindings = new ArrayList<>(sorted.size()); + for (Method method : sorted) { + bindings.add(new TagMethodBinding(method)); + } + immutableMethodsByName.put(entry.getKey(), Collections.unmodifiableList(bindings)); } return Collections.unmodifiableMap(immutableMethodsByName); } }; + /** + * How one parameter of a tag method is supplied when the tag is invoked. + */ + private enum ParameterSource { + /** The whole attribute map. */ + ATTRS, + /** The tag body, or an empty body when the tag was called without one. */ + BODY, + /** A single named attribute, looked up by the parameter's own name. */ + NAMED_ATTRIBUTE + } + + /** + * A tag method together with everything needed to build its argument array. + * + *

Classifying parameters means reading {@code Method.getParameters()}, which allocates a fresh + * array and materialises reflection metadata on every access. Doing that per invocation showed up + * directly in profiles of tag-heavy pages, and the answer never changes for a given method, so it + * is computed once when the tag library class is first seen. + */ + private static final class TagMethodBinding { + + private final Method method; + private final ParameterSource[] sources; + private final String[] attributeNames; + private final boolean[] primitive; + + private TagMethodBinding(Method method) { + this.method = method; + Parameter[] parameters = method.getParameters(); + this.sources = new ParameterSource[parameters.length]; + this.attributeNames = new String[parameters.length]; + this.primitive = new boolean[parameters.length]; + for (int i = 0; i < parameters.length; i++) { + Parameter parameter = parameters[i]; + if (isAttrsParameter(parameter)) { + sources[i] = ParameterSource.ATTRS; + } else if (isBodyParameter(parameter)) { + sources[i] = ParameterSource.BODY; + } else { + sources[i] = ParameterSource.NAMED_ATTRIBUTE; + attributeNames[i] = parameter.getName(); + primitive[i] = parameter.getType().isPrimitive(); + } + } + try { + // A public method on a Groovy class still pays an access check on every reflective + // call unless the check is suppressed once, here. + method.setAccessible(true); + } catch (RuntimeException ignored) { + // A module boundary may refuse; the call still works, it just keeps the access check. + } + } + + private Method getMethod() { + return method; + } + + /** + * @return the argument array for this method, or {@code null} when the attributes on hand + * cannot satisfy it and another overload should be tried + */ + private Object[] toArguments(Map attrs, Closure body) { + Object[] args = new Object[sources.length]; + for (int i = 0; i < sources.length; i++) { + switch (sources[i]) { + case ATTRS -> args[i] = attrs; + case BODY -> args[i] = body != null ? body : TagOutput.EMPTY_BODY_CLOSURE; + case NAMED_ATTRIBUTE -> { + // The attribute must be present in the map by parameter name. An absent + // attribute rejects this overload so resolution can try a different one. + if (attrs == null || !attrs.containsKey(attributeNames[i])) { + return null; + } + Object value = attrs.get(attributeNames[i]); + // null is a legal binding for reference-typed parameters; primitives can't take it. + if (value == null && primitive[i]) { + return null; + } + args[i] = value; + } + } + } + return args; + } + } + private TagMethodInvoker() { } @@ -153,20 +229,20 @@ public static Collection getInvokableTagMethodNames(Class tagLibClass } public static boolean hasInvokableTagMethod(GroovyObject tagLib, String tagName) { - List methods = INVOKABLE_METHODS_BY_NAME.get(tagLib.getClass()).get(tagName); - return methods != null && !methods.isEmpty(); + List bindings = INVOKABLE_METHODS_BY_NAME.get(tagLib.getClass()).get(tagName); + return bindings != null && !bindings.isEmpty(); } public static Object invokeTagMethod(GroovyObject tagLib, String tagName, Map attrs, Closure body) { - List methods = INVOKABLE_METHODS_BY_NAME.get(tagLib.getClass()).get(tagName); - if (methods == null) { + List bindings = INVOKABLE_METHODS_BY_NAME.get(tagLib.getClass()).get(tagName); + if (bindings == null) { throw new MissingMethodException(tagName, tagLib.getClass(), new Object[] { attrs, body }); } - for (Method method : methods) { - Object[] args = toMethodArguments(method, attrs, body); + for (TagMethodBinding binding : bindings) { + Object[] args = binding.toArguments(attrs, body); if (args != null) { try { - return method.invoke(tagLib, args); + return binding.getMethod().invoke(tagLib, args); } catch (IllegalAccessException e) { throw new RuntimeException(e); } catch (InvocationTargetException e) { @@ -185,46 +261,7 @@ public static Object invokeTagMethod(GroovyObject tagLib, String tagName, Map attrs, Closure body) { - Parameter[] parameters = method.getParameters(); - Object[] args = new Object[parameters.length]; - for (int i = 0; i < parameters.length; i++) { - String parameterName = parameters[i].getName(); - Class parameterType = parameters[i].getType(); - if (isAttrsParameter(parameters[i])) { - args[i] = attrs; - continue; - } - if (isBodyParameter(parameters[i])) { - args[i] = body != null ? body : TagOutput.EMPTY_BODY_CLOSURE; - continue; - } - // The attribute must be present in the map by parameter name. An absent - // attribute rejects this overload so resolution can try a different one. - if (attrs == null || !attrs.containsKey(parameterName)) { - return null; - } - Object value = attrs.get(parameterName); - // null is a legal binding for reference-typed parameters; primitives can't take it. - if (value == null && parameterType.isPrimitive()) { - return null; - } - args[i] = value; - } - return args; - } } diff --git a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TemplateNamespacedTagDispatcher.groovy b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TemplateNamespacedTagDispatcher.groovy index ccc09d06cac..7bbdf83b3a4 100644 --- a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TemplateNamespacedTagDispatcher.groovy +++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TemplateNamespacedTagDispatcher.groovy @@ -21,7 +21,6 @@ package org.grails.taglib import groovy.transform.CompileStatic import grails.core.GrailsApplication -import grails.util.Environment import org.grails.taglib.encoder.OutputContextLookupHelper @CompileStatic @@ -29,23 +28,20 @@ class TemplateNamespacedTagDispatcher extends NamespacedTagDispatcher { public static final String TEMPLATE_NAMESPACE = 'tmpl' - private boolean developmentMode = Environment.current.isDevelopmentMode() - TemplateNamespacedTagDispatcher(Class callingType, GrailsApplication application, TagLibraryLookup lookup) { super(TEMPLATE_NAMESPACE, callingType, application, lookup) } + /** + * A template name used once used to be installed onto this dispatcher's metaclass so that the next + * use of the same name bypassed methodMissing. Rendering goes through the render tag either way, + * and installing the name made every template a caller referenced a write to an + * ExpandoMetaClass whose reads are then guarded by a lock. + */ def methodMissing(String name, Object args) { - ((GroovyObject) getMetaClass()).setProperty(name, { Object[] varArgs -> - callRender(argsToAttrs(name, varArgs), filterBodyAttr(varArgs)) - }) callRender(argsToAttrs(name, args), filterBodyAttr(args)) } - protected void registerTagMetaMethods(ExpandoMetaClass emc) { - - } - protected callRender(Map attrs, Object body) { TagOutput.captureTagOutput(lookup, TagOutput.DEFAULT_NAMESPACE, 'render', attrs, body, OutputContextLookupHelper.lookupOutputContext()) } diff --git a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/discovery/AstTagLibraryView.java b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/discovery/AstTagLibraryView.java new file mode 100644 index 00000000000..4758e527dc2 --- /dev/null +++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/discovery/AstTagLibraryView.java @@ -0,0 +1,85 @@ +/* + * 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.discovery; + +import java.util.ArrayList; +import java.util.List; + +import groovy.lang.Closure; +import org.codehaus.groovy.ast.ClassHelper; +import org.codehaus.groovy.ast.ClassNode; +import org.codehaus.groovy.ast.FieldNode; +import org.codehaus.groovy.ast.MethodNode; + +/** + * A tag library read from its syntax tree, as a build reads one while compiling it. + * + * @since 8.0.0 + */ +public final class AstTagLibraryView implements TagLibraryView { + + private static final ClassNode CLOSURE_TYPE = ClassHelper.make(Closure.class); + + private final ClassNode classNode; + private final boolean parameterNamesRetained; + + public AstTagLibraryView(ClassNode classNode, boolean parameterNamesRetained) { + this.classNode = classNode; + this.parameterNamesRetained = parameterNamesRetained; + } + + @Override + public List declaredMethods() { + List declared = new ArrayList<>(); + for (MethodNode method : classNode.getMethods()) { + // A method inherited from a superclass is not dispatchable, because dispatch scans + // declared methods; a trait method is woven as a declaration and so is still seen here. + if (method.getDeclaringClass() != null && !classNode.equals(method.getDeclaringClass())) { + continue; + } + declared.add(new AstTagMethodView(method, parameterNamesRetained)); + } + return declared; + } + + @Override + public List declaredClosureFieldNames() { + List names = new ArrayList<>(); + for (FieldNode field : classNode.getFields()) { + if (field.isStatic() || field.getType() == null) { + continue; + } + // Assignability rather than equality, because the runtime asks isAssignableFrom: a field + // declared as a subclass of Closure is a tag there and has to be one here too. + if (field.getType().isDerivedFrom(CLOSURE_TYPE) || CLOSURE_TYPE.equals(field.getType())) { + names.add(field.getName()); + } + } + return names; + } + + @Override + public TagLibraryView superclassView() { + ClassNode superClass = classNode.getSuperClass(); + if (superClass == null || ClassHelper.isObjectType(superClass)) { + return null; + } + return new AstTagLibraryView(superClass, parameterNamesRetained); + } +} diff --git a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/discovery/AstTagMethodView.java b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/discovery/AstTagMethodView.java new file mode 100644 index 00000000000..bba08255caa --- /dev/null +++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/discovery/AstTagMethodView.java @@ -0,0 +1,127 @@ +/* + * 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.discovery; + +import groovy.lang.Closure; +import org.codehaus.groovy.ast.ClassHelper; +import org.codehaus.groovy.ast.ClassNode; +import org.codehaus.groovy.ast.MethodNode; +import org.codehaus.groovy.ast.Parameter; + +import grails.gsp.NotATag; +import grails.gsp.Tag; + +/** + * A method being compiled, seen through {@link TagMethodView} so that {@link TagDiscoveryRules} can + * classify it before the class exists. + * + *

Two differences from the compiled view are handled here. Parameter defaults have not yet been + * expanded into overloads, so they are reported as optional. And whether names will survive into the + * class file is a property of the compilation rather than of the method, so it is supplied by the + * caller from the compiler configuration. + * + * @since 8.0.0 + */ +public final class AstTagMethodView implements TagMethodView { + + private static final ClassNode CLOSURE_TYPE = ClassHelper.make(Closure.class); + private static final ClassNode MAP_TYPE = ClassHelper.MAP_TYPE; + private static final ClassNode TAG_ANNOTATION = ClassHelper.make(Tag.class); + private static final ClassNode NOT_A_TAG_ANNOTATION = ClassHelper.make(NotATag.class); + + private final MethodNode method; + private final Parameter[] parameters; + private final boolean parameterNamesRetained; + + /** + * @param method the method being compiled + * @param parameterNamesRetained whether this compilation writes parameter names into the class + * file, which decides whether the attributes and body parameters have to carry those names + */ + public AstTagMethodView(MethodNode method, boolean parameterNamesRetained) { + this.method = method; + this.parameters = method.getParameters(); + this.parameterNamesRetained = parameterNamesRetained; + } + + @Override + public String getName() { + return method.getName(); + } + + @Override + public boolean isPublic() { + return method.isPublic(); + } + + @Override + public boolean isStatic() { + return method.isStatic(); + } + + @Override + public boolean isGenerated() { + // Trait application produces super-accessor bridges that are synthetic once compiled but are + // not marked so on the tree; TagDiscoveryRules also rejects their names. + return method.isSynthetic() || method.isAbstract(); + } + + @Override + public boolean hasTagAnnotation() { + return !method.getAnnotations(TAG_ANNOTATION).isEmpty(); + } + + @Override + public boolean hasNotATagAnnotation() { + return !method.getAnnotations(NOT_A_TAG_ANNOTATION).isEmpty(); + } + + @Override + public int getParameterCount() { + return parameters.length; + } + + @Override + public boolean isParameterMapAssignable(int index) { + ClassNode type = parameters[index].getType(); + return type != null && (MAP_TYPE.equals(type) || type.isDerivedFrom(MAP_TYPE) || + type.implementsInterface(MAP_TYPE)); + } + + @Override + public boolean isParameterClosureAssignable(int index) { + ClassNode type = parameters[index].getType(); + return type != null && (CLOSURE_TYPE.equals(type) || type.isDerivedFrom(CLOSURE_TYPE)); + } + + @Override + public String getParameterName(int index) { + return parameters[index].getName(); + } + + @Override + public boolean isParameterNamePresent(int index) { + return parameterNamesRetained; + } + + @Override + public boolean isParameterOptional(int index) { + return parameters[index].hasInitialExpression(); + } +} diff --git a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/discovery/ReflectedTagLibraryView.java b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/discovery/ReflectedTagLibraryView.java new file mode 100644 index 00000000000..bf9cabb3b1c --- /dev/null +++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/discovery/ReflectedTagLibraryView.java @@ -0,0 +1,73 @@ +/* + * 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.discovery; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.List; + +import groovy.lang.Closure; + +/** + * A tag library read from its compiled class, as an application reads one when it registers it. + * + * @since 8.0.0 + */ +public final class ReflectedTagLibraryView implements TagLibraryView { + + private final Class type; + + public ReflectedTagLibraryView(Class type) { + this.type = type; + } + + @Override + public List declaredMethods() { + List declared = new ArrayList<>(); + for (Method method : type.getDeclaredMethods()) { + declared.add(new ReflectedTagMethodView(method)); + } + return declared; + } + + @Override + public List declaredClosureFieldNames() { + List names = new ArrayList<>(); + for (Field field : type.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers())) { + continue; + } + if (Closure.class.isAssignableFrom(field.getType())) { + names.add(field.getName()); + } + } + return names; + } + + @Override + public TagLibraryView superclassView() { + Class superClass = type.getSuperclass(); + if (superClass == null || superClass == Object.class) { + return null; + } + return new ReflectedTagLibraryView(superClass); + } +} diff --git a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/discovery/ReflectedTagMethodView.java b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/discovery/ReflectedTagMethodView.java new file mode 100644 index 00000000000..6608ab46212 --- /dev/null +++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/discovery/ReflectedTagMethodView.java @@ -0,0 +1,110 @@ +/* + * 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.discovery; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.lang.reflect.Parameter; +import java.util.Map; + +import groovy.lang.Closure; + +import grails.gsp.NotATag; +import grails.gsp.Tag; + +/** + * A compiled method, seen through {@link TagMethodView} so that {@link TagDiscoveryRules} can classify + * it at runtime. + * + *

Groovy compiles a parameter default into separate overloads, so by the time a method is + * reflected on there are no optional parameters left to report. + * + * @since 8.0.0 + */ +public final class ReflectedTagMethodView implements TagMethodView { + + private final Method method; + private final Parameter[] parameters; + + public ReflectedTagMethodView(Method method) { + this.method = method; + this.parameters = method.getParameters(); + } + + @Override + public String getName() { + return method.getName(); + } + + @Override + public boolean isPublic() { + return Modifier.isPublic(method.getModifiers()); + } + + @Override + public boolean isStatic() { + return Modifier.isStatic(method.getModifiers()); + } + + @Override + public boolean isGenerated() { + return method.isBridge() || method.isSynthetic(); + } + + @Override + public boolean hasTagAnnotation() { + return method.isAnnotationPresent(Tag.class); + } + + @Override + public boolean hasNotATagAnnotation() { + return method.isAnnotationPresent(NotATag.class); + } + + @Override + public int getParameterCount() { + return parameters.length; + } + + @Override + public boolean isParameterMapAssignable(int index) { + return Map.class.isAssignableFrom(parameters[index].getType()); + } + + @Override + public boolean isParameterClosureAssignable(int index) { + return Closure.class.isAssignableFrom(parameters[index].getType()); + } + + @Override + public String getParameterName(int index) { + return parameters[index].getName(); + } + + @Override + public boolean isParameterNamePresent(int index) { + return parameters[index].isNamePresent(); + } + + @Override + public boolean isParameterOptional(int index) { + // Defaults have already been expanded into overloads by the time the class is compiled. + return false; + } +} diff --git a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/discovery/TagDiscoveryRules.java b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/discovery/TagDiscoveryRules.java new file mode 100644 index 00000000000..a39f1336332 --- /dev/null +++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/discovery/TagDiscoveryRules.java @@ -0,0 +1,187 @@ +/* + * 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.discovery; + +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * Decides whether a method is a tag. + * + *

The single statement of those rules. Both the reflective discovery an application performs at + * startup and the syntax-tree discovery a build performs while compiling a tag library route through + * here, so the two cannot drift apart: a method is a tag for a compiler exactly when it is a tag for + * the runtime. + * + *

The rules, in order: + *

    + *
  1. plumbing — non-public, static, or compiler-generated methods are never tags;
  2. + *
  3. {@code @NotATag} excludes, {@code @Tag} includes, each overriding everything below;
  4. + *
  5. names belonging to Object, Groovy, or the framework traits are never tags;
  6. + *
  7. property accessors are never tags;
  8. + *
  9. what remains is a tag if it can be called as {@code (attrs)} or {@code (attrs, body)}.
  10. + *
+ * + * @since 8.0.0 + */ +public final class TagDiscoveryRules { + + /** + * The name a {@link java.util.Map} parameter must carry to be the attributes parameter, when the + * method retains parameter names. + */ + public static final String ATTRS_PARAMETER_NAME = "attrs"; + + /** + * The name a {@link groovy.lang.Closure} parameter must carry to be the body parameter, when the + * method retains parameter names. + */ + public static final String BODY_PARAMETER_NAME = "body"; + + /** + * Names that are Groovy or Object plumbing on any class. + */ + private static final Set LANGUAGE_METHOD_NAMES = Set.of( + "invokeMethod", "methodMissing", "propertyMissing", "getProperty", "setProperty", + "getMetaClass", "setMetaClass", "equals", "hashCode", "toString"); + + /** + * Names every tag library carries through the framework traits and lifecycle interfaces. + */ + private static final Set FRAMEWORK_METHOD_NAMES = Set.of( + "afterPropertiesSet", + "currentRequestAttributes", + "destroy", + "initializeTagLibrary", + "onApplicationEvent", + "raw", + "throwTagError", + "withCodec"); + + private TagDiscoveryRules() { + } + + /** + * @return the names that are never tags, whatever their shape + */ + public static Set getFrameworkMethodNames() { + return FRAMEWORK_METHOD_NAMES; + } + + /** + * @param method the method to classify + * @return true if the method can be invoked as a tag + */ + /** + * Finds every tag a tag library declares, from either view of it. + * + *

The two kinds are enumerated differently, because the runtime dispatches them differently. A + * method tag is read from the declaring class alone, since dispatch scans declared methods and an + * inherited one is not callable as a tag. A closure tag is read up the whole hierarchy, since + * dispatch finds it as a property and a property is inherited. + * + * @param view the tag library, from a syntax tree or from a compiled class + * @return every tag name the library declares + */ + public static Set findTags(TagLibraryView view) { + Set tags = new LinkedHashSet<>(); + for (TagMethodView method : view.declaredMethods()) { + if (isTagMethod(method)) { + tags.add(method.getName()); + } + } + // A closure tag is read up the whole hierarchy, since dispatch finds it as a property and a + // property is inherited, where a method tag is read from the declaring class alone because + // dispatch scans declared methods. + for (TagLibraryView current = view; current != null; current = current.superclassView()) { + tags.addAll(current.declaredClosureFieldNames()); + } + return tags; + } + + public static boolean isTagMethod(TagMethodView method) { + if (!method.isPublic() || method.isStatic() || method.isGenerated()) { + return false; + } + if (method.hasNotATagAnnotation()) { + return false; + } + if (method.hasTagAnnotation()) { + return true; + } + String name = method.getName(); + if (name.isEmpty() || name.charAt(0) == '<' || name.indexOf('$') >= 0) { + return false; + } + if (LANGUAGE_METHOD_NAMES.contains(name) || FRAMEWORK_METHOD_NAMES.contains(name)) { + return false; + } + if (isPropertyAccessor(method, name)) { + return false; + } + return hasInvocableTagShape(method); + } + + private static boolean isPropertyAccessor(TagMethodView method, String name) { + int parameterCount = method.getParameterCount(); + if (parameterCount == 0 && (name.startsWith("get") || name.startsWith("is"))) { + return true; + } + return parameterCount == 1 && name.startsWith("set"); + } + + /** + * A tag is called as {@code (attrs)} or {@code (attrs, body)}. Parameters with default values + * produce further overloads, so every arity the declaration can be called at is considered. + */ + private static boolean hasInvocableTagShape(TagMethodView method) { + int parameterCount = method.getParameterCount(); + int required = 0; + for (int i = 0; i < parameterCount; i++) { + if (!method.isParameterOptional(i)) { + required++; + } + } + for (int arity = Math.max(required, 1); arity <= parameterCount; arity++) { + if (arity == 1 && (isAttrs(method, 0) || isBody(method, 0))) { + return true; + } + if (arity == 2 && isAttrs(method, 0) && isBody(method, 1)) { + return true; + } + } + return false; + } + + private static boolean isAttrs(TagMethodView method, int index) { + return method.isParameterMapAssignable(index) && carriesName(method, index, ATTRS_PARAMETER_NAME); + } + + private static boolean isBody(TagMethodView method, int index) { + return method.isParameterClosureAssignable(index) && carriesName(method, index, BODY_PARAMETER_NAME); + } + + /** + * A parameter qualifies when it carries the expected name, or when the method does not retain + * parameter names and there is nothing to check against. + */ + private static boolean carriesName(TagMethodView method, int index, String expectedName) { + return !method.isParameterNamePresent(index) || expectedName.equals(method.getParameterName(index)); + } +} diff --git a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/discovery/TagLibraryAstDiscovery.java b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/discovery/TagLibraryAstDiscovery.java new file mode 100644 index 00000000000..7957d895149 --- /dev/null +++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/discovery/TagLibraryAstDiscovery.java @@ -0,0 +1,88 @@ +/* + * 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.discovery; + +import java.util.Set; + +import groovy.lang.Closure; +import org.codehaus.groovy.ast.ClassHelper; +import org.codehaus.groovy.ast.ClassNode; +import org.codehaus.groovy.ast.FieldNode; +import org.codehaus.groovy.ast.expr.ConstantExpression; +import org.codehaus.groovy.ast.expr.Expression; + +/** + * Reads a tag library's namespace and tag names from its syntax tree. + * + *

Classification is delegated to {@link TagDiscoveryRules}, the same rules an application applies + * when it registers tag libraries, so the two cannot disagree about what a tag is. What remains here + * is reading the namespace and gathering the candidate members from the tree. + * + * @since 8.0.0 + */ +public final class TagLibraryAstDiscovery { + + public static final String DEFAULT_NAMESPACE = "g"; + + private static final String NAMESPACE_FIELD = "namespace"; + + private static final ClassNode CLOSURE_TYPE = ClassHelper.make(Closure.class); + + private TagLibraryAstDiscovery() { + } + + /** + * Resolves the namespace the way {@code DefaultGrailsTagLibClass} does at runtime, which reads the + * static {@code namespace} property through the class hierarchy. + * + * @param classNode the tag library + * @return the namespace, or {@code null} when it cannot be determined without running the code, in + * which case no descriptor should be written and the tag library resolves dynamically + */ + public static String resolveNamespace(ClassNode classNode) { + for (ClassNode current = classNode; current != null && !ClassHelper.isObjectType(current); + current = current.getSuperClass()) { + FieldNode namespaceField = current.getDeclaredField(NAMESPACE_FIELD); + if (namespaceField == null || !namespaceField.isStatic()) { + continue; + } + Expression initial = namespaceField.getInitialExpression(); + if (initial instanceof ConstantExpression constant && constant.getValue() != null) { + String value = constant.getValue().toString().trim(); + return value.isEmpty() ? DEFAULT_NAMESPACE : value; + } + // Declared, but its value is only known once the initialiser runs - a reference to a shared + // constant, a concatenation, and so on. Guessing "g" here would file the tags under the + // wrong namespace, so the tag library is left out of the index entirely. + return null; + } + return DEFAULT_NAMESPACE; + } + + /** + * @param classNode the tag library + * @param parameterNamesRetained whether this compilation writes parameter names into the class file + * @return every tag name the library declares + */ + public static Set findTags(ClassNode classNode, + boolean parameterNamesRetained) { + return TagDiscoveryRules.findTags(new AstTagLibraryView(classNode, parameterNamesRetained)); + } + +} diff --git a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/discovery/TagLibraryView.java b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/discovery/TagLibraryView.java new file mode 100644 index 00000000000..e80e86bc2ca --- /dev/null +++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/discovery/TagLibraryView.java @@ -0,0 +1,53 @@ +/* + * 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.discovery; + +import java.util.List; + +/** + * A tag library as the rules need to read it, whether from a syntax tree or from a compiled class. + * + *

{@link TagMethodView} lets the two agree on whether a method is a tag. This lets them agree on + * which members to ask about in the first place, which is the other half of the same question: a tag + * declared as a {@code Closure} field is inherited, so enumerating one class is not enough, while a + * tag declared as a method is not, because dispatch reads declared methods only. Keeping the walk + * here rather than once per side is what stops the index describing a different set of tags from the + * one an application registers. + * + * @since 8.0.0 + */ +public interface TagLibraryView { + + /** + * @return the methods this class itself declares, excluding anything inherited + */ + List declaredMethods(); + + /** + * @return the names of the non-static fields this class itself declares whose type is a + * {@code Closure}, including a subclass of one + */ + List declaredClosureFieldNames(); + + /** + * @return the superclass to continue the walk with, or {@code null} at the top of the hierarchy + * or where the superclass cannot be read + */ + TagLibraryView superclassView(); +} diff --git a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/discovery/TagMethodView.java b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/discovery/TagMethodView.java new file mode 100644 index 00000000000..ca46314f939 --- /dev/null +++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/discovery/TagMethodView.java @@ -0,0 +1,103 @@ +/* + * 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.discovery; + +/** + * The properties of a method that decide whether it is a tag. + * + *

Tag discovery happens twice: over {@code java.lang.reflect.Method} when an application is + * running, and over the abstract syntax tree while a tag library is compiled. The two must reach the + * same answer — a method treated as a tag by one and not the other either fails to dispatch after + * compiling cleanly, or is reported as unknown despite being callable. + * + *

This is the abstraction that lets {@link TagDiscoveryRules} be the only place those rules are + * written, with a small adapter for each source of truth rather than a second implementation. + * + * @since 8.0.0 + */ +public interface TagMethodView { + + /** + * @return the method name + */ + String getName(); + + /** + * @return true if the method is public + */ + boolean isPublic(); + + /** + * @return true if the method is static + */ + boolean isStatic(); + + /** + * @return true if the method was generated by the compiler rather than written, covering bridge + * methods and the accessors trait application produces + */ + boolean isGenerated(); + + /** + * @return true if the method is annotated {@code @Tag}, which makes it a tag whatever its shape + */ + boolean hasTagAnnotation(); + + /** + * @return true if the method is annotated {@code @NotATag}, which excludes it whatever its shape + */ + boolean hasNotATagAnnotation(); + + /** + * @return the number of declared parameters + */ + int getParameterCount(); + + /** + * @param index a parameter position + * @return true if a {@link java.util.Map} may be passed for this parameter + */ + boolean isParameterMapAssignable(int index); + + /** + * @param index a parameter position + * @return true if a {@link groovy.lang.Closure} may be passed for this parameter + */ + boolean isParameterClosureAssignable(int index); + + /** + * @param index a parameter position + * @return the parameter's name, meaningful only when {@link #isParameterNamePresent(int)} is true + */ + String getParameterName(int index); + + /** + * @param index a parameter position + * @return true if the compiled method retains parameter names, which decides whether a parameter + * has to be named {@code attrs} or {@code body} to be recognised as such + */ + boolean isParameterNamePresent(int index); + + /** + * @param index a parameter position + * @return true if the parameter has a default value, so that Groovy will also generate overloads + * that omit it + */ + boolean isParameterOptional(int index); +} diff --git a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/index/TagLibraryIndex.java b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/index/TagLibraryIndex.java new file mode 100644 index 00000000000..213930e368e --- /dev/null +++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/index/TagLibraryIndex.java @@ -0,0 +1,526 @@ +/* + * 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.index; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.Enumeration; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Properties; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.WeakHashMap; + +/** + * The set of tag libraries and tag names known at compile time. + * + *

Each tag library contributes one descriptor under {@value #INDEX_LOCATION}, written by the + * {@code TagLib} AST transformation as the tag library is compiled. Descriptors are per class rather + * than per module so that libraries packaged in separate jars merge on the classpath without any + * build step having to combine them, in the same way {@code META-INF/services} entries do. + * + *

Reading the index answers "which tags exist in namespace x" without loading or reflecting over a + * single tag library class, which is what allows GSP expressions to be resolved when a page is + * compiled rather than dispatched dynamically when it renders. + * + * @since 8.0.0 + */ +public final class TagLibraryIndex { + + /** + * Classpath directory holding one descriptor per compiled tag library. + */ + public static final String INDEX_LOCATION = "META-INF/grails/taglibs/"; + + /** + * Descriptor format this build writes and understands. A descriptor carrying anything else was + * produced by a different version of Grails and is ignored, so its tags resolve dynamically rather + * than being read under the wrong set of rules. + */ + public static final int FORMAT_VERSION = 1; + + /** + * Settings the build states for the compilation the index is read in, written alongside the + * descriptors by the build and deliberately not packaged into the artifact: they describe how this + * project is compiled, not what its tag libraries declare. + */ + public static final String SETTINGS_LOCATION = INDEX_LOCATION + "compile-settings.properties"; + + /** + * What the index could not describe, written by whatever produced it. + * + *

An index generated before its project is compiled cannot always read every tag library: one + * referring to a type that does not exist yet, in a language it cannot parse, or generated by the + * build itself, is left out. A namespace missing some of its tags must not have a call to one of + * them reported as a misspelling, so what was missed is recorded rather than left to be inferred + * from the absence. + */ + public static final String INCOMPLETE_LOCATION = INDEX_LOCATION + "incomplete.properties"; + + static final String VERSION_KEY = "version"; + static final String NAMESPACE_KEY = "namespace"; + static final String CLASS_KEY = "class"; + static final String TAGS_KEY = "tags"; + static final String STRICT_KEY = "strictTags"; + static final String INCOMPLETE_NAMESPACES_KEY = "namespaces"; + static final String INCOMPLETE_ALL_KEY = "all"; + static final String DYNAMIC_NAMESPACES_KEY = "dynamicTagNamespaces"; + static final String UNQUALIFIED_KEY = "unqualifiedTagCalls"; + static final String LOCAL_NAMESPACES_KEY = "localNamespaces"; + + /** + * One index per class loader. A compilation gets a class loader of its own, so this is read once + * per compilation rather than once per source file, and is not held after that compilation ends. + * Caching in a plain static field instead would carry one project's tag libraries into the next + * compilation in the same Gradle daemon. + */ + private static final Map BY_CLASS_LOADER = + Collections.synchronizedMap(new WeakHashMap<>()); + + private final Map> byNamespace; + private final Map> ambiguousByNamespace; + private final Map> tagNamesByClass; + private final boolean strict; + private final Set dynamicNamespaces; + private final Set incompleteNamespaces; + private final boolean everythingIncomplete; + private final boolean unqualifiedCalls; + private final Set localNamespaces; + + private TagLibraryIndex(Map> byNamespace, + Map> ambiguousByNamespace, Map> tagNamesByClass, + boolean strict, Set dynamicNamespaces, Set incompleteNamespaces, + boolean everythingIncomplete, boolean unqualifiedCalls, Set localNamespaces) { + this.byNamespace = byNamespace; + this.ambiguousByNamespace = ambiguousByNamespace; + this.tagNamesByClass = tagNamesByClass; + this.strict = strict; + this.dynamicNamespaces = dynamicNamespaces; + this.incompleteNamespaces = incompleteNamespaces; + this.everythingIncomplete = everythingIncomplete; + this.unqualifiedCalls = unqualifiedCalls; + this.localNamespaces = localNamespaces; + } + + /** + * Reads the index for a class loader, reusing the one already read for it. + * + *

Reading walks every jar on the classpath, so a compiler that consults the index for each + * source file it compiles would walk it once per file. Use this from compilation; use + * {@link #load(ClassLoader)} where a fresh read is wanted. + * + * @param classLoader the loader to scan; when {@code null} the thread context loader is used + * @return the merged index, never {@code null} + */ + public static TagLibraryIndex forClassLoader(ClassLoader classLoader) { + ClassLoader loader = classLoader != null ? classLoader : Thread.currentThread().getContextClassLoader(); + if (loader == null) { + return load(null); + } + return BY_CLASS_LOADER.computeIfAbsent(loader, TagLibraryIndex::load); + } + + /** + * Reads every tag library descriptor visible to the given class loader. + * + * @param classLoader the loader to scan; when {@code null} the thread context loader is used + * @return the merged index, never {@code null} + */ + public static TagLibraryIndex load(ClassLoader classLoader) { + ClassLoader loader = classLoader != null ? classLoader : Thread.currentThread().getContextClassLoader(); + Map> merged = new TreeMap<>(); + Map> ambiguous = new TreeMap<>(); + Map> byClass = new TreeMap<>(); + if (loader == null) { + return new TagLibraryIndex(merged, ambiguous, byClass, false, Collections.emptySet(), + Collections.emptySet(), false, false, Collections.emptySet()); + } + // A directory resource enumerates its children on some classpath layouts but not inside jars, + // so the descriptors are discovered through the manifest of names each descriptor records + // rather than by listing the directory. + for (URL url : listDescriptors(loader)) { + Properties properties = read(url); + if (properties == null) { + continue; + } + if (!String.valueOf(FORMAT_VERSION).equals(properties.getProperty(VERSION_KEY))) { + continue; + } + String namespace = properties.getProperty(NAMESPACE_KEY); + String className = properties.getProperty(CLASS_KEY); + String tags = properties.getProperty(TAGS_KEY, ""); + if (namespace == null || namespace.isEmpty() || className == null || className.isEmpty()) { + continue; + } + // Recorded from the descriptor rather than from its tags, so that a tag library declaring + // none of them is still known to have been described. Deciding that from the tags alone + // would have such a tag library described twice. + byClass.computeIfAbsent(className, k -> new TreeSet<>()); + Map tagsForNamespace = + merged.computeIfAbsent(namespace, k -> new TreeMap<>()); + for (String encodedTag : tags.split(",")) { + String trimmed = encodedTag.trim(); + if (trimmed.isEmpty()) { + continue; + } + // Recorded against the declaring class before ambiguity is considered, so that asking + // what one tag library declares is answered from its own descriptor and is unaffected + // by whether some other tag library happens to declare the same name. + byClass.computeIfAbsent(className, k -> new TreeSet<>()).add(trimmed); + TagLibraryIndexEntry existing = tagsForNamespace.get(trimmed); + if (existing != null && !existing.tagLibraryClassName().equals(className)) { + // At runtime the tag library registered last wins, and registration order comes + // from artefact scanning rather than from classpath order, so which of these two + // will win cannot be known here. Resolving it either way risks compiling against + // one implementation and dispatching to the other, so the tag is marked ambiguous + // and left to runtime resolution. + ambiguous.computeIfAbsent(namespace, k -> new TreeSet<>()).add(trimmed); + continue; + } + tagsForNamespace.put(trimmed, + new TagLibraryIndexEntry(namespace, trimmed, className, true)); + } + } + Properties settings = readSettings(loader); + boolean strict = Boolean.parseBoolean(settings.getProperty(STRICT_KEY, "false")); + boolean unqualified = Boolean.parseBoolean(settings.getProperty(UNQUALIFIED_KEY, "false")); + Set local = new TreeSet<>(); + for (String namespace : settings.getProperty(LOCAL_NAMESPACES_KEY, "").split(",")) { + String trimmed = namespace.trim(); + if (!trimmed.isEmpty()) { + local.add(trimmed); + } + } + Set dynamic = new TreeSet<>(); + for (String namespace : settings.getProperty(DYNAMIC_NAMESPACES_KEY, "").split(",")) { + String trimmed = namespace.trim(); + if (!trimmed.isEmpty()) { + dynamic.add(trimmed); + } + } + Set incomplete = new TreeSet<>(); + boolean allIncomplete = false; + for (URL url : urls(loader, INCOMPLETE_LOCATION)) { + Properties recorded = read(url); + if (recorded == null) { + continue; + } + allIncomplete |= Boolean.parseBoolean(recorded.getProperty(INCOMPLETE_ALL_KEY, "false")); + for (String namespace : recorded.getProperty(INCOMPLETE_NAMESPACES_KEY, "").split(",")) { + String trimmed = namespace.trim(); + if (!trimmed.isEmpty()) { + incomplete.add(trimmed); + } + } + } + return new TagLibraryIndex(merged, ambiguous, byClass, strict, + Collections.unmodifiableSet(dynamic), Collections.unmodifiableSet(incomplete), + allIncomplete, unqualified, Collections.unmodifiableSet(local)); + } + + private static Set urls(ClassLoader loader, String location) { + Set found = new LinkedHashSet<>(); + try { + Enumeration resources = loader.getResources(location); + while (resources.hasMoreElements()) { + found.add(resources.nextElement()); + } + } + catch (IOException unreadable) { + return found; + } + return found; + } + + /** + * Reads the settings the build states for this compilation. Only the project being compiled + * contributes them, so the first one found wins rather than several being merged. + */ + private static Properties readSettings(ClassLoader loader) { + URL url = loader.getResource(SETTINGS_LOCATION); + if (url == null) { + return new Properties(); + } + Properties settings = read(url); + return settings != null ? settings : new Properties(); + } + + private static Set listDescriptors(ClassLoader loader) { + Set urls = new LinkedHashSet<>(); + try { + Enumeration manifests = loader.getResources(INDEX_LOCATION + "index.properties"); + while (manifests.hasMoreElements()) { + URL manifest = manifests.nextElement(); + Properties names = read(manifest); + if (names == null) { + continue; + } + for (String className : names.stringPropertyNames()) { + // Resolved against the manifest that names it rather than searched for on the + // classpath. A descriptor always sits beside its own manifest, and asking the + // loader instead would walk every classpath entry once per tag library - a few + // hundred full walks for an application with a few hundred of them. + URL descriptor = resolveSibling(manifest, className + ".properties"); + if (descriptor != null) { + urls.add(descriptor); + } + } + } + } catch (IOException e) { + // A classpath that cannot be enumerated yields no statically known tags, which degrades to + // the dynamic dispatch that was in place before the index existed. + return urls; + } + return urls; + } + + /** + * Resolves a descriptor against the manifest naming it. + * + *

Built with the {@link URL} constructor, deprecated since JDK 20, deliberately: a manifest + * inside a jar is addressed by an opaque {@code jar:} URI, which {@link java.net.URI#resolve} + * cannot resolve a relative name against, and round-tripping the URL through {@code URI} breaks + * on characters {@code ClassLoader.getResources} does not encode. The constructor is the tool + * that works here, so the warning is suppressed rather than the call rewritten.

+ * + * @param manifest the manifest naming the descriptor + * @param fileName the descriptor's file name + * @return the descriptor beside that manifest, or {@code null} when it cannot be addressed + */ + @SuppressWarnings("deprecation") + private static URL resolveSibling(URL manifest, String fileName) { + try { + return new URL(manifest, fileName); + } + catch (java.net.MalformedURLException e) { + return null; + } + } + + private static Properties read(URL url) { + try (InputStream in = url.openStream()) { + Properties properties = new Properties(); + try (Reader reader = new InputStreamReader(in, StandardCharsets.UTF_8)) { + properties.load(reader); + } + return properties; + } catch (IOException e) { + return null; + } + } + + /** + * @param namespace a tag library namespace, for example {@code g} + * @return true if any compiled tag library declared that namespace + */ + public boolean hasNamespace(String namespace) { + return byNamespace.containsKey(namespace); + } + + /** + * @param namespace a tag library namespace + * @param tagName a tag name within that namespace + * @return the declaring tag library, or {@code null} when the tag is not statically known + */ + public TagLibraryIndexEntry lookup(String namespace, String tagName) { + if (isAmbiguous(namespace, tagName)) { + return null; + } + Map tags = byNamespace.get(namespace); + return tags != null ? tags.get(tagName) : null; + } + + /** + * Whether more than one tag library declares this tag, in which case which one the runtime will + * dispatch to depends on registration order and cannot be decided here. + * + * @param namespace a tag library namespace + * @param tagName a tag name within that namespace + * @return true when the tag is declared by more than one tag library + */ + public boolean isAmbiguous(String namespace, String tagName) { + Set ambiguousTags = ambiguousByNamespace.get(namespace); + return ambiguousTags != null && ambiguousTags.contains(tagName); + } + + /** + * @return every namespace contributed by a compiled tag library + */ + public Set getNamespaces() { + return Collections.unmodifiableSet(new TreeSet<>(byNamespace.keySet())); + } + + /** + * @param namespace a tag library namespace + * @return the tag names declared in that namespace, empty when the namespace is unknown + */ + public Set getTagNames(String namespace) { + Map tags = byNamespace.get(namespace); + return tags != null ? Collections.unmodifiableSet(new TreeSet<>(tags.keySet())) : + Collections.emptySet(); + } + + /** + * The tags a given tag library declares, as recorded when it was compiled. + * + *

Answered from that tag library's own descriptor, so a tag it declares is reported whether or + * not another tag library declares the same name. Which of two tag libraries answers to a name at + * runtime is a separate question, asked through {@link #lookup} and {@link #isAmbiguous}. + * + *

This describes the class as it was compiled. A class that has since been reloaded, or one + * built without a descriptor, is not described here and has to be asked directly. + * + * @param tagLibraryClassName the binary name of a tag library + * @return its tags, or an empty set when it has no descriptor + */ + public Set getTagNamesForClass(String tagLibraryClassName) { + if (tagLibraryClassName == null) { + return Collections.emptySet(); + } + Set tagNames = tagNamesByClass.get(tagLibraryClassName); + return tagNames != null ? Collections.unmodifiableSet(new TreeSet<>(tagNames)) : + Collections.emptySet(); + } + + /** + * Whether a descriptor for this tag library already exists. + * + *

Lets a tag library being compiled tell whether something has already described it - the build + * generating the index ahead of compilation - so that it does not write a second, separately + * maintained copy. A tag library the build did not manage to describe is not covered here and + * describes itself instead. + * + * @param tagLibraryClassName the binary name of a tag library + * @return true when a descriptor for it was read + */ + public boolean isClassDescribed(String tagLibraryClassName) { + return tagLibraryClassName != null && tagNamesByClass.containsKey(tagLibraryClassName); + } + + /** + * Whether the build asked for a tag no compiled tag library declares to fail compilation. + * + * @return true when the build set {@code grails.compileStatic.strictTags} + */ + /** + * Whether a call written without a namespace may be compiled into an invocation. + * + *

Off unless the build asks for it. A namespaced call says which tag library it means; an + * unqualified one is a bare name, and whether that name is a tag depends on what else answers to + * it - a method Groovy gives every object, a delegate the enclosing closure is handed, an + * overload the tag library also declares. The compiler can rule those out only as far as it can + * see, so the default is to leave such a call to be dispatched as it always was. + * + * @return true when the build set {@code grails.compileStatic.unqualifiedTagCalls} + */ + public boolean rewritesUnqualifiedCalls() { + return unqualifiedCalls; + } + + /** + * Whether this project's own tag libraries declare a namespace. + * + *

What a namespace holds is only fully knowable for the namespaces this project describes. + * Every other namespace is contributed to by tag libraries from elsewhere - a plugin built before + * descriptors existed, one registered while the application runs - and a tag missing from such a + * namespace is as likely to be one of those as a misspelling. + * + * @param namespace a tag library namespace + * @return true when a tag library of this project declares it + */ + public boolean declaresNamespace(String namespace) { + return localNamespaces.contains(namespace); + } + + public boolean isStrict() { + return strict; + } + + /** + * Namespaces the build declared as filled in at runtime, whose tags are therefore never reported + * as unknown however complete the index is. + * + * @return the declared dynamic namespaces, empty when none were declared + */ + public Set getDynamicNamespaces() { + return dynamicNamespaces; + } + + /** + * Whether everything in a namespace was described. + * + *

An index generated before its project is compiled may not have been able to read every tag + * library: one referring to a type that does not exist yet, written in a language it cannot parse, + * or generated by the build. A tag missing from an incomplete namespace is not evidence of a + * misspelling, so nothing about it should be reported. + * + * @param namespace a tag library namespace + * @return true when every tag library contributing to it was described + */ + public boolean isNamespaceComplete(String namespace) { + return !this.everythingIncomplete && !this.incompleteNamespaces.contains(namespace); + } + + /** + * @param namespace a tag library namespace + * @return true when the build declared this namespace as filled in at runtime + */ + public boolean isDynamicNamespace(String namespace) { + return namespace != null && dynamicNamespaces.contains(namespace); + } + + /** + * Whether a compiled tag library declares this tag, including one declared by more than one of + * them. Such a tag exists; which tag library answers to it is settled at runtime. + * + * @param namespace a tag library namespace + * @param tagName a tag name within that namespace + * @return true when the tag is known to the index + */ + public boolean isKnown(String namespace, String tagName) { + if (isAmbiguous(namespace, tagName)) { + return true; + } + Map tags = byNamespace.get(namespace); + return tags != null && tags.containsKey(tagName); + } + + /** + * @return true when no compiled tag library was found, in which case callers must fall back to + * runtime resolution + */ + public boolean isEmpty() { + return byNamespace.isEmpty(); + } + + @Override + public String toString() { + Map> summary = new LinkedHashMap<>(); + byNamespace.forEach((ns, tags) -> summary.put(ns, tags.keySet())); + return "TagLibraryIndex" + summary; + } +} diff --git a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/index/TagLibraryIndexEntry.java b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/index/TagLibraryIndexEntry.java new file mode 100644 index 00000000000..3103a2f9cfa --- /dev/null +++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/index/TagLibraryIndexEntry.java @@ -0,0 +1,32 @@ +/* + * 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.index; + +/** + * One tag recorded in the {@link TagLibraryIndex} at compile time. + * + * @param namespace the tag library namespace the tag is reachable through + * @param tagName the tag name within that namespace + * @param tagLibraryClassName the binary name of the tag library declaring the tag + * @param acceptsBody whether the tag can be called with a body + * @since 8.0.0 + */ +public record TagLibraryIndexEntry(String namespace, String tagName, String tagLibraryClassName, + boolean acceptsBody) { +} diff --git a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/index/TagLibraryIndexGenerator.java b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/index/TagLibraryIndexGenerator.java new file mode 100644 index 00000000000..f285132d4bf --- /dev/null +++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/index/TagLibraryIndexGenerator.java @@ -0,0 +1,431 @@ +/* + * 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.index; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; +import java.util.stream.Stream; + +import org.codehaus.groovy.ast.AnnotationNode; +import org.codehaus.groovy.ast.ClassNode; +import org.codehaus.groovy.ast.FieldNode; +import org.codehaus.groovy.ast.expr.ConstantExpression; +import org.codehaus.groovy.control.ClassNodeResolver; +import org.codehaus.groovy.control.CompilationUnit; +import org.codehaus.groovy.control.CompilerConfiguration; +import org.codehaus.groovy.control.Phases; + +import org.grails.taglib.discovery.TagLibraryAstDiscovery; + +/** + * Writes the tag library index for a source set. + * + *

Sources are parsed to the point where the syntax tree is complete and no further, so a tag + * library is never loaded or executed to find out what it declares. Reading the tree rather than the + * text means the answer follows Groovy's own understanding of the source. + * + *

The index is rewritten in full each time rather than added to. A tag library that has been + * renamed or deleted therefore disappears from it, where an index accumulated as each class compiled + * would keep describing tags that no longer exist until the build directory was cleaned. + * + *

Invoked in a forked process by the build, with the source set's own compile classpath, because + * the rules it applies belong to the framework rather than to the build tooling. + * + * @since 8.0.0 + */ +public final class TagLibraryIndexGenerator { + + private static final String TAG_LIB_ANNOTATION = "grails.gsp.TagLib"; + private static final String ARTEFACT_ANNOTATION = "grails.artefact.Artefact"; + private static final String TAG_LIB_ARTEFACT = "TagLib"; + + private static final String NAMESPACE_FIELD = "namespace"; + + private TagLibraryIndexGenerator() { + } + + /** + * @param args the output directory, whether parameter names are retained, the source encoding, how + * many source directories follow, those directories, and then the source roots a type this + * project declares may be resolved from + */ + public static void main(String[] args) throws IOException { + if (args.length < 4) { + throw new IllegalArgumentException("Usage: " + + " ... ..."); + } + File outputDir = new File(args[0]); + boolean parameterNamesRetained = Boolean.parseBoolean(args[1]); + String encoding = args[2].isEmpty() ? "UTF-8" : args[2]; + int sourceDirCount = Integer.parseInt(args[3]); + List sourceDirs = new ArrayList<>(sourceDirCount); + List resolutionRoots = new ArrayList<>(); + for (int i = 4; i < args.length; i++) { + (i - 4 < sourceDirCount ? sourceDirs : resolutionRoots).add(new File(args[i])); + } + generate(sourceDirs, resolutionRoots, outputDir, parameterNamesRetained, encoding); + } + + /** + * Regenerates the index describing every tag library under a source directory. + * + * @param sourceDir the directory to scan for tag libraries + * @param outputDir the directory the index is written beneath + * @param parameterNamesRetained whether the compilation writes parameter names into class files + * @param encoding the source encoding + * @throws IOException if the index cannot be written + */ + public static void generate(File sourceDir, File outputDir, boolean parameterNamesRetained, + String encoding) throws IOException { + generate(Collections.singletonList(sourceDir), Collections.emptyList(), outputDir, + parameterNamesRetained, encoding); + } + + /** + * Regenerates the index, resolving a type this project declares from its source. + * + * @param sourceDirs the directories to scan for tag libraries + * @param outputDir the directory the index is written beneath + * @param parameterNamesRetained whether the compilation writes parameter names into class files + * @param encoding the source encoding + * @throws IOException if the index cannot be written + */ + public static void generate(List sourceDirs, File outputDir, boolean parameterNamesRetained, + String encoding) throws IOException { + generate(sourceDirs, Collections.emptyList(), outputDir, parameterNamesRetained, encoding); + } + + /** + * Regenerates the index describing every tag library under any of several source directories. + * + *

All of them are described in one pass. Describing them one at a time would mean either + * erasing the previous directory's descriptors or leaving behind descriptors for tag libraries + * that have since been renamed or deleted. + * + * @param sourceDirs the directories to scan for tag libraries + * @param resolutionRoots the source roots a type this project declares may be resolved from, so + * that a base class, trait or parameter type it supplies is read rather than guessed + * @param outputDir the directory the index is written beneath + * @param parameterNamesRetained whether the compilation writes parameter names into class files + * @param encoding the source encoding + * @throws IOException if the index cannot be written + */ + public static void generate(List sourceDirs, List resolutionRoots, File outputDir, + boolean parameterNamesRetained, String encoding) throws IOException { + TagLibraryIndexWriter.clear(outputDir); + List sources = new ArrayList<>(); + if (sourceDirs != null) { + for (File sourceDir : sourceDirs) { + if (sourceDir != null && sourceDir.isDirectory()) { + sources.addAll(findGroovySources(sourceDir)); + } + } + } + if (sources.isEmpty()) { + return; + } + + List roots = resolutionRoots != null ? resolutionRoots : Collections.emptyList(); + List skipped = new ArrayList<>(); + // The resolver adds a collaborator's source to the compilation unit so its type can be read, + // which puts that class in the parse output too. Only the sources this generator was pointed + // at may be described: a helper named *TagLib under src/main/groovy would otherwise be filed + // as a tag library of the default namespace, making its methods g tags that either collide + // with real ones - silently disabling rewriting for that name - or resolve to a tag that does + // not exist at runtime. + Set describable = new HashSet<>(); + for (File source : sources) { + describable.add(source.getAbsolutePath()); + } + for (ClassNode classNode : parse(sources, roots, parameterNamesRetained, encoding, skipped)) { + if (!isTagLibrary(classNode) || !isRegistrable(classNode) || + !wasAskedFor(classNode, describable)) { + continue; + } + String namespace = TagLibraryAstDiscovery.resolveNamespace(classNode); + if (namespace == null) { + // Only knowable once the initialiser runs, so recording it would file the tags under + // a guess. Left out, which leaves the tag library to runtime resolution. + continue; + } + TagLibraryIndexWriter.write(outputDir, classNode.getName(), namespace, + TagLibraryAstDiscovery.findTags(classNode, parameterNamesRetained)); + } + recordWhatWasMissed(outputDir, skipped, encoding); + } + + /** + * Records the namespaces left incomplete by whatever could not be read, so that a call to a tag of + * one of them is never reported as a misspelling. + */ + private static void recordWhatWasMissed(File outputDir, List skipped, String encoding) + throws IOException { + Set namespaces = new TreeSet<>(); + boolean everything = false; + for (File source : skipped) { + String namespace = declaredNamespace(source, encoding); + if (namespace != null) { + namespaces.add(namespace); + } + else { + everything = true; + } + } + TagLibraryIndexWriter.writeIncomplete(outputDir, namespaces, everything); + } + + /** + * Parses the sources far enough to describe them. + * + *

A tag library referring to something outside this directory and off the classpath given here, + * such as a service in the same project, cannot be resolved before that project is compiled. Those + * are parsed on their own and skipped when they still fail, rather than losing the index for every + * other tag library alongside them. What was skipped is recorded, so that nothing in a namespace + * missing some of its tags is reported; a build that writes the index describes it again once the + * project has been compiled, and until then its tags resolve dynamically, exactly as a tag library + * with no descriptor does. + */ + private static List parse(List sources, List resolutionRoots, + boolean parameterNamesRetained, String encoding, List skippedOut) { + try { + return collectClassNodes(compile(sources, resolutionRoots, parameterNamesRetained, encoding)); + } catch (Exception wholeSourceSetFailed) { + List classNodes = new ArrayList<>(); + for (File source : sources) { + try { + classNodes.addAll(collectClassNodes( + compile(List.of(source), resolutionRoots, parameterNamesRetained, encoding))); + } catch (Exception singleSourceFailed) { + skippedOut.add(source); + } + } + if (!skippedOut.isEmpty()) { + List names = new ArrayList<>(); + for (File skipped : skippedOut) { + names.add(skipped.getName()); + } + System.out.println("Tag library index: could not read " + String.join(", ", names) + + " before compilation; their tags resolve dynamically until they are compiled."); + } + classNodes.sort(Comparator.comparing(ClassNode::getName)); + return classNodes; + } + } + + /** + * The namespace a source that could not be described declares, read from its syntax tree. + * + *

Only ever used to record which namespace is missing some of its tags. Naming the wrong one + * leaves the real one looking complete, which is exactly when a call to a tag that does exist gets + * reported as one that does not, so this claims a namespace only where the source leaves no room + * for doubt: one tag library in the file, declaring its own namespace as a constant. + * + *

Anything else - a namespace field on some other class in the file, more than one tag library, + * a namespace inherited from a base class that may not have resolved, or none stated at all - + * yields nothing, and every namespace is then treated as incomplete. That costs diagnostics rather + * than inventing an error. + * + * @return the namespace, or {@code null} when this source does not plainly state one + */ + private static String declaredNamespace(File source, String encoding) { + try { + CompilerConfiguration configuration = new CompilerConfiguration(); + configuration.setSourceEncoding(encoding); + CompilationUnit unit = new CompilationUnit(configuration); + unit.addSource(source); + // Conversion builds the tree and stops before resolving anything, so a type this project + // has not compiled yet cannot make it fail. + unit.compile(Phases.CONVERSION); + + ClassNode candidate = null; + for (ClassNode classNode : collectClassNodes(unit)) { + if (!isTagLibrary(classNode)) { + continue; + } + if (candidate != null) { + // Which of them failed is not knowable, so neither is claimed. + return null; + } + candidate = classNode; + } + if (candidate == null) { + return null; + } + + FieldNode field = candidate.getDeclaredField(NAMESPACE_FIELD); + if (field == null || !field.isStatic()) { + // Either the default namespace or one inherited from a base class whose resolution is + // the very thing in doubt. Not distinguishable here, so not claimed. + return null; + } + if (!(field.getInitialExpression() instanceof ConstantExpression constant) || + constant.getValue() == null) { + return null; + } + String namespace = constant.getValue().toString().trim(); + return namespace.isEmpty() ? null : namespace; + } + catch (Exception unparseable) { + return null; + } + } + + private static CompilationUnit compile(List sources, List resolutionRoots, + boolean parameterNamesRetained, String encoding) { + CompilerConfiguration configuration = new CompilerConfiguration(); + configuration.setParameters(parameterNamesRetained); + configuration.setSourceEncoding(encoding); + CompilationUnit unit = new CompilationUnit(configuration); + if (!resolutionRoots.isEmpty()) { + unit.setClassNodeResolver(new SourceRootClassNodeResolver(resolutionRoots)); + } + for (File source : sources) { + unit.addSource(source); + } + // Canonicalization is the last phase before bytecode, by which point traits are applied and + // annotations resolved, and it stops short of generating or loading any class. + unit.compile(Phases.CANONICALIZATION); + return unit; + } + + /** + * Resolves a type this project declares by compiling its source alongside the tag library that + * refers to it. + * + *

A tag library commonly refers to something the same project declares - a service it injects, + * a base class it extends, a trait it carries - and none of those exist as classes yet when the + * index is generated. Compiling their source too is what the Groovy compiler does for types within + * one compilation, and is what lets a tag library be described exactly as it will be once built. + * + *

Deliberately not a stand-in class node. What is missing decides what a tag library declares: + * a base class carries the namespace, a trait carries tags, and a parameter type decides whether a + * method is a tag at all. Answering with a placeholder would file a tag library under the wrong + * namespace, or leave out tags the running application has, and the index would then disagree with + * what the application does - which is the one thing it must never do. A type that cannot be found + * in source is left unresolved, and the tag library referring to it is skipped as before. + */ + private static final class SourceRootClassNodeResolver extends ClassNodeResolver { + + private final List roots; + + private SourceRootClassNodeResolver(List roots) { + this.roots = roots; + } + + @Override + public LookupResult resolveName(String name, CompilationUnit compilationUnit) { + LookupResult onTheClasspath = super.resolveName(name, compilationUnit); + if (onTheClasspath != null) { + return onTheClasspath; + } + File source = findSource(name); + if (source == null) { + // Not something this project declares. Left unresolved so that resolution carries on + // to the next candidate a star import offers, and so that a name that is simply + // misspelled still fails rather than being quietly invented. + return null; + } + return new LookupResult(compilationUnit.addSource(source), null); + } + + private File findSource(String name) { + String relativePath = name.replace('.', File.separatorChar) + ".groovy"; + for (File root : roots) { + File candidate = new File(root, relativePath); + if (candidate.isFile()) { + return candidate; + } + } + return null; + } + } + + private static List collectClassNodes(CompilationUnit unit) { + List classNodes = new ArrayList<>(); + unit.getAST().getModules().forEach(module -> classNodes.addAll(module.getClasses())); + // Sorted so that the index is identical for identical sources regardless of the order the + // file system enumerated them, keeping the build reproducible. + classNodes.sort(Comparator.comparing(ClassNode::getName)); + return classNodes; + } + + private static boolean isTagLibrary(ClassNode classNode) { + for (AnnotationNode annotation : classNode.getAnnotations()) { + String annotationName = annotation.getClassNode().getName(); + if (TAG_LIB_ANNOTATION.equals(annotationName)) { + return true; + } + if (ARTEFACT_ANNOTATION.equals(annotationName)) { + var member = annotation.getMember("value"); + if (member != null && TAG_LIB_ARTEFACT.equals(member.getText())) { + return true; + } + } + } + return classNode.getName().endsWith(TAG_LIB_ARTEFACT); + } + + /** + * Whether a class can be registered as a tag library at all. + * + *

An abstract class cannot: artefact handling rejects one, and {@code TagLibArtefactHandler} + * does not allow abstract artefacts. A base class shared by several tag libraries is a normal + * thing to keep beside them, so describing it would file its methods under a namespace nothing + * answers to - and its subclasses do not inherit them as tags either, since a tag method is read + * from the declaring class. Traits and interfaces are covered by the same check. + * + * @param classNode the class to consider + * @return true when an application could register it + */ + private static boolean isRegistrable(ClassNode classNode) { + return !classNode.isAbstract() && !classNode.isInterface(); + } + + /** + * @param classNode a class the compilation produced + * @param describable the absolute paths of the sources this generator was given + * @return whether the class came from one of those sources rather than from a resolved collaborator + */ + private static boolean wasAskedFor(ClassNode classNode, Set describable) { + if (classNode.getModule() == null || classNode.getModule().getContext() == null) { + return false; + } + String name = classNode.getModule().getContext().getName(); + return name != null && describable.contains(new File(name).getAbsolutePath()); + } + + private static List findGroovySources(File sourceDir) throws IOException { + try (Stream paths = Files.walk(sourceDir.toPath())) { + return paths.filter(Files::isRegularFile) + .filter(path -> path.toString().endsWith(".groovy")) + .sorted() + .map(Path::toFile) + .toList(); + } + } +} diff --git a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/index/TagLibraryIndexWriter.java b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/index/TagLibraryIndexWriter.java new file mode 100644 index 00000000000..46549bdb255 --- /dev/null +++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/index/TagLibraryIndexWriter.java @@ -0,0 +1,212 @@ +/* + * 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.index; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.StandardOpenOption; +import java.util.Collection; +import java.util.Properties; +import java.util.TreeSet; + +/** + * Writes the compile-time descriptor for a single tag library. + * + *

Two files are produced per tag library: a descriptor named after the tag library class, and an + * entry in a shared {@code index.properties} manifest naming it. The manifest exists because a + * classpath directory cannot be enumerated from inside a jar, so the reader needs the names up front. + * Both live under {@link TagLibraryIndex#INDEX_LOCATION} and merge across jars without a build step. + * + * @since 8.0.0 + */ +public final class TagLibraryIndexWriter { + + /** + * Serialises the read-modify-write of the shared manifest across threads of this JVM. + */ + private static final Object MANIFEST_MONITOR = new Object(); + + private TagLibraryIndexWriter() { + } + + /** + * Removes any index previously written beneath a directory, so that a regenerated index describes + * only the tag libraries that exist now. Without this a renamed or deleted tag library would keep + * a descriptor, and the manifest naming it, until the build directory was cleaned. + * + * @param outputDirectory the directory the index is written beneath + * @throws IOException if an existing index cannot be removed + */ + public static void clear(File outputDirectory) throws IOException { + if (outputDirectory == null) { + return; + } + File indexDirectory = new File(outputDirectory, TagLibraryIndex.INDEX_LOCATION); + File[] existing = indexDirectory.listFiles(); + if (existing == null) { + return; + } + for (File file : existing) { + if (file.isFile() && file.getName().endsWith(".properties")) { + Files.deleteIfExists(file.toPath()); + } + } + } + + /** + * Writes the descriptor for a tag library into a compiler output directory. + * + * @param outputDirectory the compilation target directory; nothing is written when {@code null} + * @param className the binary name of the tag library + * @param namespace the namespace the tag library declares + * @param tagNames the tag names the tag library declares + * @throws IOException if the descriptor cannot be written + */ + public static void write(File outputDirectory, String className, String namespace, + Collection tagNames) throws IOException { + if (outputDirectory == null || className == null || className.isEmpty() || + namespace == null || namespace.isEmpty()) { + return; + } + File indexDirectory = new File(outputDirectory, TagLibraryIndex.INDEX_LOCATION); + if (!indexDirectory.isDirectory() && !indexDirectory.mkdirs() && !indexDirectory.isDirectory()) { + return; + } + + Properties descriptor = new Properties(); + descriptor.setProperty(TagLibraryIndex.VERSION_KEY, String.valueOf(TagLibraryIndex.FORMAT_VERSION)); + descriptor.setProperty(TagLibraryIndex.NAMESPACE_KEY, namespace); + descriptor.setProperty(TagLibraryIndex.CLASS_KEY, className); + // Sorted so that recompiling unchanged sources produces byte-identical output, which keeps + // the build reproducible and avoids spurious up-to-date checks failing downstream. + descriptor.setProperty(TagLibraryIndex.TAGS_KEY, String.join(",", new TreeSet<>(tagNames))); + store(new File(indexDirectory, className + ".properties"), descriptor); + + addToManifest(new File(indexDirectory, "index.properties"), className); + } + + /** + * Adds one class to the manifest naming every described tag library. + * + *

The manifest is shared by every tag library compiled into the same directory, and adding to + * it is a read, a change and a write back. Two compilations writing to one directory at the same + * time - joint compilation, or parallel tasks sharing an output - would otherwise interleave and + * one would write back a copy that never saw the other's entry. The lost entry is silent: the + * descriptor is there, nothing names it, so its tags simply resolve dynamically for evermore. + * + *

Guarded twice, because the two cases are different. The monitor covers threads in this JVM, + * which is what joint compilation and a parallel Gradle task within one daemon are. The file lock + * covers a second process, which a forked compiler or a second daemon is; it is advisory and only + * held for the read-modify-write. + * + * @param manifest the manifest to add to + * @param className the tag library to name in it + * @throws IOException if the manifest cannot be read or written + */ + private static void addToManifest(File manifest, String className) throws IOException { + synchronized (MANIFEST_MONITOR) { + try (FileChannel channel = FileChannel.open(manifest.toPath(), + StandardOpenOption.CREATE, StandardOpenOption.READ, StandardOpenOption.WRITE)) { + try (FileLock ignored = channel.lock()) { + Properties names = new Properties(); + channel.position(0); + // Reads the channel rather than reopening the file, so the content read is the + // content the lock is held over. + byte[] existing = new byte[(int) channel.size()]; + ByteBuffer buffer = ByteBuffer.wrap(existing); + while (buffer.hasRemaining() && channel.read(buffer) >= 0) { + // read until the buffer is filled or the channel is exhausted + } + if (existing.length > 0) { + names.load(new InputStreamReader(new ByteArrayInputStream(existing), + StandardCharsets.UTF_8)); + } + names.setProperty(className, ""); + byte[] updated = render(names).getBytes(StandardCharsets.UTF_8); + channel.truncate(0); + channel.position(0); + channel.write(ByteBuffer.wrap(updated)); + } + } + } + } + + /** + * Records what could not be described, so that a call to a tag of an incompletely described + * namespace is never reported as a misspelling. + * + * @param outputDirectory the directory the index is written beneath + * @param namespaces the namespaces known to be missing some of their tags + * @param everything true when what was missed could not be attributed to a namespace at all, in + * which case nothing in the index may be treated as complete + * @throws IOException if the record cannot be written + */ + public static void writeIncomplete(File outputDirectory, Collection namespaces, + boolean everything) throws IOException { + if (outputDirectory == null) { + return; + } + if (namespaces.isEmpty() && !everything) { + return; + } + File indexDirectory = new File(outputDirectory, TagLibraryIndex.INDEX_LOCATION); + if (!indexDirectory.isDirectory() && !indexDirectory.mkdirs() && !indexDirectory.isDirectory()) { + return; + } + Properties recorded = new Properties(); + recorded.setProperty(TagLibraryIndex.INCOMPLETE_NAMESPACES_KEY, + String.join(",", new TreeSet<>(namespaces))); + recorded.setProperty(TagLibraryIndex.INCOMPLETE_ALL_KEY, String.valueOf(everything)); + store(new File(indexDirectory, "incomplete.properties"), recorded); + } + + private static void store(File file, Properties properties) throws IOException { + try (OutputStream out = Files.newOutputStream(file.toPath()); + Writer writer = new OutputStreamWriter(out, StandardCharsets.UTF_8)) { + writer.write(render(properties)); + } + } + + /** + * @param properties the entries to write + * @return the properties as text, sorted and without the timestamp comment {@code Properties.store} + * stamps in, which would make otherwise identical builds differ + */ + private static String render(Properties properties) { + StringBuilder text = new StringBuilder(); + for (String key : new TreeSet<>(properties.stringPropertyNames())) { + text.append(escape(key)).append('=').append(escape(properties.getProperty(key))).append('\n'); + } + return text.toString(); + } + + private static String escape(String value) { + return value.replace("\\", "\\\\").replace("=", "\\=").replace(":", "\\:"); + } +} diff --git a/grails-gsp/grails-taglib/src/test/groovy/org/grails/taglib/index/TagLibraryIndexSpec.groovy b/grails-gsp/grails-taglib/src/test/groovy/org/grails/taglib/index/TagLibraryIndexSpec.groovy new file mode 100644 index 00000000000..62007aaad9c --- /dev/null +++ b/grails-gsp/grails-taglib/src/test/groovy/org/grails/taglib/index/TagLibraryIndexSpec.groovy @@ -0,0 +1,340 @@ +/* + * 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.index + +import java.nio.file.Files +import java.nio.file.Path +import java.util.jar.JarEntry +import java.util.jar.JarOutputStream + +import spock.lang.Specification +import spock.lang.TempDir + +/** + * The index is written per tag library class so that libraries packaged in separate jars merge on the + * classpath with no build step combining them. These exercise that merge directly, including the + * cases where two jars contribute to one namespace and where they disagree about the same tag. + */ +class TagLibraryIndexSpec extends Specification { + + @TempDir + Path tempDir + + void 'the format the Gradle plugin writes is the format read here'() { + expect: 'the plugin cannot reference these constants, so it restates them and pins them in ' + + 'TagLibraryIndexFilesSpec; renaming either side without the other fails one of the two' + TagLibraryIndex.INDEX_LOCATION == 'META-INF/grails/taglibs/' + TagLibraryIndex.SETTINGS_LOCATION == 'META-INF/grails/taglibs/compile-settings.properties' + TagLibraryIndex.STRICT_KEY == 'strictTags' + TagLibraryIndex.DYNAMIC_NAMESPACES_KEY == 'dynamicTagNamespaces' + TagLibraryIndex.UNQUALIFIED_KEY == 'unqualifiedTagCalls' + TagLibraryIndex.LOCAL_NAMESPACES_KEY == 'localNamespaces' + } + + void 'tag libraries in separate jars merge into one namespace'() { + given: + URLClassLoader loader = loaderOver( + jar('a.jar', [( 'com.a.OneTagLib'): ['g', 'alpha,beta']]), + jar('b.jar', [(' com.b.TwoTagLib'.trim()): ['g', 'gamma']])) + + when: + TagLibraryIndex index = TagLibraryIndex.load(loader) + + then: + index.getTagNames('g') == ['alpha', 'beta', 'gamma'] as Set + index.lookup('g', 'alpha').tagLibraryClassName() == 'com.a.OneTagLib' + index.lookup('g', 'gamma').tagLibraryClassName() == 'com.b.TwoTagLib' + + cleanup: + loader.close() + } + + void 'separate namespaces stay separate'() { + given: + URLClassLoader loader = loaderOver( + jar('a.jar', [('com.a.OneTagLib'): ['g', 'alpha']]), + jar('b.jar', [('com.b.TwoTagLib'): ['f', 'alpha']])) + + when: + TagLibraryIndex index = TagLibraryIndex.load(loader) + + then: + index.namespaces == ['f', 'g'] as Set + index.lookup('g', 'alpha').tagLibraryClassName() == 'com.a.OneTagLib' + index.lookup('f', 'alpha').tagLibraryClassName() == 'com.b.TwoTagLib' + + cleanup: + loader.close() + } + + void 'an empty classpath yields an empty index rather than failing'() { + given: + URLClassLoader loader = loaderOver() + + expect: + TagLibraryIndex.load(loader).isEmpty() + + cleanup: + loader.close() + } + + void 'a descriptor missing its namespace or class is ignored'() { + given: + Path incomplete = tempDir.resolve('bad.jar') + new JarOutputStream(Files.newOutputStream(incomplete)).withCloseable { jar -> + jar.putNextEntry(new JarEntry(TagLibraryIndex.INDEX_LOCATION + 'index.properties')) + jar.write('com.bad.BrokenTagLib=\n'.bytes) + jar.closeEntry() + jar.putNextEntry(new JarEntry(TagLibraryIndex.INDEX_LOCATION + 'com.bad.BrokenTagLib.properties')) + jar.write('tags=orphan\n'.bytes) + jar.closeEntry() + } + URLClassLoader loader = loaderOver(incomplete) + + expect: 'a malformed descriptor leaves the tag unknown, so it resolves dynamically' + TagLibraryIndex.load(loader).lookup('g', 'orphan') == null + + cleanup: + loader.close() + } + + private URLClassLoader loaderOver(Path... jars) { + new URLClassLoader(jars.collect { it.toUri().toURL() } as URL[], (ClassLoader) null) + } + + void 'a tag declared by two tag libraries is ambiguous and is not resolved statically'() { + given: 'two jars whose tag libraries both declare g:shared' + URLClassLoader loader = loaderOver( + jar('a.jar', [('com.a.OneTagLib'): ['g', 'shared,onlyA']]), + jar('b.jar', [('com.b.TwoTagLib'): ['g', 'shared,onlyB']])) + + when: + TagLibraryIndex index = TagLibraryIndex.load(loader) + + then: 'which one wins depends on registration order at runtime, so it is left unresolved' + index.isAmbiguous('g', 'shared') + index.lookup('g', 'shared') == null + + and: 'tags declared by only one of them still resolve' + index.lookup('g', 'onlyA').tagLibraryClassName() == 'com.a.OneTagLib' + index.lookup('g', 'onlyB').tagLibraryClassName() == 'com.b.TwoTagLib' + + cleanup: + loader.close() + } + + void 'the same tag library seen twice on the classpath is not ambiguous'() { + given: 'the same descriptor present in two jars, as a duplicated dependency produces' + URLClassLoader loader = loaderOver( + jar('a.jar', [('com.a.OneTagLib'): ['g', 'shared']]), + jar('b.jar', [('com.a.OneTagLib'): ['g', 'shared']])) + + expect: 'it names one implementation, so there is nothing to disambiguate' + TagLibraryIndex.load(loader).lookup('g', 'shared').tagLibraryClassName() == 'com.a.OneTagLib' + + cleanup: + loader.close() + } + + void 'a closure based tag is recorded like any other'() { + given: + Path jarPath = tempDir.resolve('legacy.jar') + new JarOutputStream(Files.newOutputStream(jarPath)).withCloseable { jar -> + jar.putNextEntry(new JarEntry(TagLibraryIndex.INDEX_LOCATION + 'index.properties')) + jar.write('com.legacy.OldTagLib=\n'.bytes) + jar.closeEntry() + jar.putNextEntry(new JarEntry(TagLibraryIndex.INDEX_LOCATION + 'com.legacy.OldTagLib.properties')) + jar.write(("version=${TagLibraryIndex.FORMAT_VERSION}\nclass=com.legacy.OldTagLib\n" + + 'namespace=legacy\ntags=asMethod,asClosure\n').bytes) + jar.closeEntry() + } + URLClassLoader loader = loaderOver(jarPath) + + when: + TagLibraryIndex index = TagLibraryIndex.load(loader) + + then: 'both are known, so neither is reported as a misspelling' + index.getTagNames('legacy') == ['asClosure', 'asMethod'] as Set + + and: 'and both resolve, since a call selects the tag by name either way' + index.lookup('legacy', 'asMethod') != null + index.lookup('legacy', 'asClosure') != null + + cleanup: + loader.close() + } + + void 'a descriptor written by a different format version is ignored'() { + given: + Path other = tempDir.resolve('future.jar') + new JarOutputStream(Files.newOutputStream(other)).withCloseable { jar -> + jar.putNextEntry(new JarEntry(TagLibraryIndex.INDEX_LOCATION + 'index.properties')) + jar.write('com.future.NewTagLib=\n'.bytes) + jar.closeEntry() + jar.putNextEntry(new JarEntry(TagLibraryIndex.INDEX_LOCATION + 'com.future.NewTagLib.properties')) + jar.write("version=${TagLibraryIndex.FORMAT_VERSION + 1}\nclass=com.future.NewTagLib\nnamespace=g\ntags=future\n".bytes) + jar.closeEntry() + } + URLClassLoader loader = loaderOver(other) + + expect: 'its tags resolve dynamically rather than being read under the wrong rules' + TagLibraryIndex.load(loader).lookup('g', 'future') == null + + cleanup: + loader.close() + } + + void 'the tags a tag library declares are read from its own descriptor'() { + given: 'two tag libraries in one namespace, one of whose tags the other also declares' + URLClassLoader loader = loaderOver( + jar('a.jar', [('com.a.OneTagLib'): ['g', 'alpha,shared']]), + jar('b.jar', [('com.b.TwoTagLib'): ['g', 'shared']])) + + when: + TagLibraryIndex index = TagLibraryIndex.load(loader) + + then: 'each is described by what it declares, whatever the other declares' + index.getTagNamesForClass('com.a.OneTagLib') == ['alpha', 'shared'] as Set + index.getTagNamesForClass('com.b.TwoTagLib') == ['shared'] as Set + + and: 'which of them answers to the shared name is still left to runtime' + index.isAmbiguous('g', 'shared') + index.lookup('g', 'shared') == null + + and: 'but the tag exists, so it is never reported as a misspelling' + index.isKnown('g', 'shared') + + cleanup: + loader.close() + } + + void 'a tag library declaring no tags is still known to have been described'() { + given: 'otherwise it would be described a second time by the compiler' + Path empty = tempDir.resolve('empty.jar') + new JarOutputStream(Files.newOutputStream(empty)).withCloseable { jar -> + jar.putNextEntry(new JarEntry(TagLibraryIndex.INDEX_LOCATION + 'index.properties')) + jar.write('com.a.NoTagsTagLib=\n'.bytes) + jar.closeEntry() + jar.putNextEntry(new JarEntry(TagLibraryIndex.INDEX_LOCATION + 'com.a.NoTagsTagLib.properties')) + jar.write("version=${TagLibraryIndex.FORMAT_VERSION}\nclass=com.a.NoTagsTagLib\nnamespace=empty\ntags=\n".bytes) + jar.closeEntry() + } + URLClassLoader loader = loaderOver(empty) + + when: + TagLibraryIndex index = TagLibraryIndex.load(loader) + + then: + index.isClassDescribed('com.a.NoTagsTagLib') + index.getTagNamesForClass('com.a.NoTagsTagLib').isEmpty() + + cleanup: + loader.close() + } + + void 'a tag library with no descriptor is not described'() { + given: + URLClassLoader loader = loaderOver(jar('a.jar', [('com.a.OneTagLib'): ['g', 'alpha']])) + + expect: + !TagLibraryIndex.load(loader).isClassDescribed('com.other.AbsentTagLib') + + cleanup: + loader.close() + } + + void 'a tag library with no descriptor is described by nothing'() { + given: + URLClassLoader loader = loaderOver(jar('a.jar', [('com.a.OneTagLib'): ['g', 'alpha']])) + + expect: + TagLibraryIndex.load(loader).getTagNamesForClass('com.other.AbsentTagLib').isEmpty() + + cleanup: + loader.close() + } + + void 'the index is read once per class loader'() { + given: 'reading walks every jar on the classpath, so a compiler must not repeat it per file' + URLClassLoader loader = loaderOver(jar('a.jar', [('com.a.OneTagLib'): ['g', 'alpha']])) + + expect: + TagLibraryIndex.forClassLoader(loader).is(TagLibraryIndex.forClassLoader(loader)) + + and: 'and a different class loader, as the next compilation has, reads its own' + !TagLibraryIndex.forClassLoader(loader).is( + TagLibraryIndex.forClassLoader(loaderOver(jar('c.jar', [('com.c.TagLib'): ['g', 'beta']])))) + + cleanup: + loader.close() + } + + void 'the settings the build declared are read alongside the descriptors'() { + given: + Path settings = tempDir.resolve('settings.jar') + new JarOutputStream(Files.newOutputStream(settings)).withCloseable { jar -> + jar.putNextEntry(new JarEntry(TagLibraryIndex.SETTINGS_LOCATION)) + jar.write('strictTags=true\ndynamicTagNamespaces=legacy, other\n'.bytes) + jar.closeEntry() + } + URLClassLoader loader = loaderOver(settings, jar('a.jar', [('com.a.OneTagLib'): ['g', 'alpha']])) + + when: + TagLibraryIndex index = TagLibraryIndex.load(loader) + + then: + index.strict + index.dynamicNamespaces == ['legacy', 'other'] as Set + index.isDynamicNamespace('legacy') + !index.isDynamicNamespace('g') + + cleanup: + loader.close() + } + + void 'a build that declared nothing is left permissive'() { + given: + URLClassLoader loader = loaderOver(jar('a.jar', [('com.a.OneTagLib'): ['g', 'alpha']])) + + expect: + !TagLibraryIndex.load(loader).strict + TagLibraryIndex.load(loader).dynamicNamespaces.isEmpty() + + cleanup: + loader.close() + } + + private Path jar(String name, Map> tagLibs) { + Path path = tempDir.resolve(name) + new JarOutputStream(Files.newOutputStream(path)).withCloseable { jar -> + jar.putNextEntry(new JarEntry(TagLibraryIndex.INDEX_LOCATION + 'index.properties')) + jar.write(tagLibs.keySet().collect { "${it}=\n" }.join().bytes) + jar.closeEntry() + tagLibs.each { String className, List namespaceAndTags -> + jar.putNextEntry(new JarEntry(TagLibraryIndex.INDEX_LOCATION + className + '.properties')) + String encodedTags = namespaceAndTags[1].split(',') + .join(',') + jar.write(("version=${TagLibraryIndex.FORMAT_VERSION}\nclass=${className}\n" + + "namespace=${namespaceAndTags[0]}\ntags=${encodedTags}\n").bytes) + jar.closeEntry() + } + } + path + } +} diff --git a/grails-gsp/grails-taglib/src/test/groovy/org/grails/taglib/index/TagLibraryIndexWriterConcurrencySpec.groovy b/grails-gsp/grails-taglib/src/test/groovy/org/grails/taglib/index/TagLibraryIndexWriterConcurrencySpec.groovy new file mode 100644 index 00000000000..8edd5fccc6b --- /dev/null +++ b/grails-gsp/grails-taglib/src/test/groovy/org/grails/taglib/index/TagLibraryIndexWriterConcurrencySpec.groovy @@ -0,0 +1,127 @@ +/* + * 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.index + +import java.nio.file.Files +import java.nio.file.Path +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.ExecutorService +import java.util.concurrent.TimeUnit + +import spock.lang.Specification +import spock.lang.TempDir + +/** + * Every tag library compiled into one directory adds itself to a manifest they all share, which is a + * read, a change and a write back. + * + *

Two compilations writing to the same directory at once - joint compilation, or parallel tasks + * sharing an output - would interleave without guarding, and a writer would put back a copy that never + * saw another's entry. Losing an entry is silent: the descriptor exists, nothing names it, so the tag + * library is simply never discovered and its tags resolve dynamically for evermore. + */ +class TagLibraryIndexWriterConcurrencySpec extends Specification { + + @TempDir + Path tempDir + + void 'every tag library written at once is named in the manifest'() { + given: + File destination = Files.createDirectory(tempDir.resolve('out')).toFile() + int writers = 32 + ExecutorService pool = Executors.newFixedThreadPool(8) + CountDownLatch start = new CountDownLatch(1) + CountDownLatch done = new CountDownLatch(writers) + + when: 'they all write into the same directory, released together to maximise overlap' + List failures = Collections.synchronizedList([]) + (0.. + pool.submit { + try { + start.await() + TagLibraryIndexWriter.write(destination, "demo.TagLib${i}".toString(), 'demo', + ["tag${i}".toString()]) + } + catch (Throwable t) { + failures << t + } + finally { + done.countDown() + } + } + } + start.countDown() + done.await(60, TimeUnit.SECONDS) + pool.shutdown() + + and: 'nothing failed on the way' + assert failures.isEmpty(), failures.collect { it.toString() }.join('; ') + + then: 'the manifest names all of them, not just whichever wrote last' + Properties manifest = manifestIn(destination) + manifest.stringPropertyNames() == (0.. + pool.submit { + try { + TagLibraryIndexWriter.write(destination, "demo.Read${i}".toString(), 'readback', + ["tag${i}".toString()]) + } + finally { + done.countDown() + } + } + } + done.await(60, TimeUnit.SECONDS) + pool.shutdown() + + and: + URLClassLoader loader = new URLClassLoader([destination.toURI().toURL()] as URL[], (ClassLoader) null) + TagLibraryIndex index = TagLibraryIndex.load(loader) + + then: 'which is what a lost manifest entry would silently take away' + index.getTagNames('readback') == (0..Every tag in every namespace used to be installed onto this tag library's metaclass here, so + * that a tag library calling another tag found a method rather than falling through to + * methodMissing. Tags are resolved through the tag library lookup instead, so there is nothing to + * install and nothing to initialise. + * + *

It cannot simply be deleted. A trait method is part of the binary contract: Groovy weaves a + * call to the generated helper into every implementing class, so a tag library from a plugin + * compiled against an earlier release calls this method by name at construction. Removing it + * raises NoSuchMethodError for every such tag library - which is what happened when it was. + */ @PostConstruct void initializeTagLibrary() { - if (!Environment.isDevelopmentMode()) { - TagLibraryMetaUtils.enhanceTagLibMetaClass(GrailsMetaClassUtils.getExpandoMetaClass(getClass()), getTagLibraryLookup(), getTaglibNamespace()) - } } Object raw(Object value) { @@ -175,16 +182,6 @@ trait TagLibrary implements WebAttributes, ServletAttributes, TagLibraryInvoker } } } - if (result != null && !Environment.isDevelopmentMode()) { - MetaClass mc = GrailsMetaClassUtils.getExpandoMetaClass(getClass()) - - // Register the property for the already-existing singleton instance of the taglib - TagLibraryMetaUtils.registerPropertyMissingForTag(this.metaClass, name, result) - - // Register the property for the ExpandoMetaClass so that other tag libs that inherit from it benefit - TagLibraryMetaUtils.registerPropertyMissingForTag(mc, name, result) - } - if (result != null) { return result } diff --git a/grails-gsp/grails-web-taglib/src/main/groovy/grails/artefact/gsp/TagLibraryInvoker.groovy b/grails-gsp/grails-web-taglib/src/main/groovy/grails/artefact/gsp/TagLibraryInvoker.groovy index 0b1c6bfcd8f..002aec203e6 100644 --- a/grails-gsp/grails-web-taglib/src/main/groovy/grails/artefact/gsp/TagLibraryInvoker.groovy +++ b/grails-gsp/grails-web-taglib/src/main/groovy/grails/artefact/gsp/TagLibraryInvoker.groovy @@ -22,7 +22,6 @@ import groovy.transform.CompileStatic import org.springframework.beans.factory.annotation.Autowired -import grails.util.Environment import grails.util.GrailsMetaClassUtils import grails.web.api.WebAttributes import org.grails.taglib.NamespacedTagDispatcher @@ -43,7 +42,6 @@ import org.codehaus.groovy.runtime.InvokerHelper trait TagLibraryInvoker extends WebAttributes { private TagLibraryLookup tagLibraryLookup - private boolean developmentMode = Environment.isDevelopmentMode() @Autowired(required = false) void setTagLibraryLookup(TagLibraryLookup tagLibraryLookup) { @@ -93,11 +91,14 @@ trait TagLibraryInvoker extends WebAttributes { } if (tagLibrary) { - if (!developmentMode) { - MetaClass thisMc = GrailsMetaClassUtils.getMetaClass(this) - TagLibraryMetaUtils.registerMethodMissingForTags(thisMc, lookup, usedNamespace, methodName) - } - return tagLibrary.invokeMethod(methodName, args) + // Resolving the tag used to install it onto this object's metaclass so that later + // calls bypassed methodMissing. That made every caller mutate its own + // ExpandoMetaClass the first time it used a tag, and made every later call pay the + // read lock guarding an initialised metaclass. The tag is dispatched through the + // lookup each time instead, which is a map read. + return TagLibraryMetaUtils.methodMissingForTagLib( + GrailsMetaClassUtils.getMetaClass(this), getClass(), lookup, + usedNamespace, methodName, args, false) } } } @@ -124,9 +125,8 @@ trait TagLibraryInvoker extends WebAttributes { TagLibraryLookup lookup = getTagLibraryLookup() NamespacedTagDispatcher namespacedTagDispatcher = lookup?.lookupNamespaceDispatcher(propertyName) if (namespacedTagDispatcher) { - if (!developmentMode) { - TagLibraryMetaUtils.registerPropertyMissingForTag(GrailsMetaClassUtils.getMetaClass(this), propertyName, namespacedTagDispatcher) - } + // As above: the namespace is resolved through the lookup rather than installed as a + // property on this object's metaclass. return namespacedTagDispatcher } diff --git a/grails-gsp/grails-web-taglib/src/main/groovy/grails/gsp/taglib/compiler/CompiledTagCallRewriter.java b/grails-gsp/grails-web-taglib/src/main/groovy/grails/gsp/taglib/compiler/CompiledTagCallRewriter.java new file mode 100644 index 00000000000..2ebc1b3368b --- /dev/null +++ b/grails-gsp/grails-web-taglib/src/main/groovy/grails/gsp/taglib/compiler/CompiledTagCallRewriter.java @@ -0,0 +1,616 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package grails.gsp.taglib.compiler; + +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import groovy.lang.GroovySystem; +import groovy.lang.MetaMethod; +import org.codehaus.groovy.ast.AnnotationNode; +import org.codehaus.groovy.ast.ClassCodeExpressionTransformer; +import org.codehaus.groovy.ast.ClassHelper; +import org.codehaus.groovy.ast.ClassNode; +import org.codehaus.groovy.ast.ConstructorNode; +import org.codehaus.groovy.ast.DynamicVariable; +import org.codehaus.groovy.ast.FieldNode; +import org.codehaus.groovy.ast.MethodNode; +import org.codehaus.groovy.ast.Parameter; +import org.codehaus.groovy.ast.Variable; +import org.codehaus.groovy.ast.expr.ArgumentListExpression; +import org.codehaus.groovy.ast.expr.ClosureExpression; +import org.codehaus.groovy.ast.expr.ConstantExpression; +import org.codehaus.groovy.ast.expr.Expression; +import org.codehaus.groovy.ast.expr.MapExpression; +import org.codehaus.groovy.ast.expr.MethodCallExpression; +import org.codehaus.groovy.ast.expr.PropertyExpression; +import org.codehaus.groovy.ast.expr.StaticMethodCallExpression; +import org.codehaus.groovy.ast.expr.TupleExpression; +import org.codehaus.groovy.ast.expr.VariableExpression; +import org.codehaus.groovy.ast.stmt.Statement; +import org.codehaus.groovy.control.SourceUnit; + +import org.grails.compiler.injection.GrailsASTUtils; +import org.grails.taglib.CompiledTagInvocation; +import org.grails.taglib.discovery.TagLibraryAstDiscovery; +import org.grails.taglib.index.TagLibraryIndex; + +/** + * Rewrites a call to a known tag into a direct invocation. + * + *

Writing {@code g.message(code: 'x')} reaches the tag library through {@code propertyMissing} to + * find the namespace and {@code invokeMethod} to find the tag, which is a dynamic call site even in a + * statically compiled class. Both the namespace and the tag name are fixed in the source, and the tag + * library index says whether that tag exists, so the call is replaced with a call to + * {@link CompiledTagInvocation}, an ordinary static method call. + * + *

The tag is still selected by name at runtime, through the same lookup the dynamic path uses, so + * a tag library registered later, one that overrides another, 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. + * + *

A namespace the index does not know is left alone, which is what keeps a tag library registered + * at runtime working, as is a name that something else in scope already answers to. + * + * @since 8.0.0 + */ +public class CompiledTagCallRewriter extends ClassCodeExpressionTransformer { + + private static final ClassNode INVOCATION_TYPE = ClassHelper.make(CompiledTagInvocation.class); + private static final String LOOKUP_ACCESSOR = "getTagLibraryLookup"; + private static final String OUTPUT_CONTEXT_ACCESSOR = "getOutputContext"; + private static final String INVOKE = "invoke"; + private static final String INVOKE_ARGUMENTS = "invokeArguments"; + private static final String INVOKE_ARGUMENTS_IN_CONTEXT = "invokeArgumentsInContext"; + private static final String GROOVY_PAGE_TYPE = "org.grails.gsp.GroovyPage"; + private static final String COMPILE_STATIC_TYPE = "groovy.transform.CompileStatic"; + private static final String GRAILS_COMPILE_STATIC_TYPE = "grails.compiler.GrailsCompileStatic"; + private static final String MARKUP_TAG_CALL = "invokeTag"; + private static final String DEFAULT_NAMESPACE = "g"; + + /** + * Names an unqualified call never reaches a tag through, however the index reads. + * + *

Two kinds. {@code body} and {@code render} the dispatch treats as its own before it ever + * considers a tag. The rest is every name the metaclass answers to for an arbitrary receiver — + * {@code DefaultGroovyMethods} and any extension module on the compiler's classpath. Those are + * real methods on every object: an unqualified {@code each { }} or {@code with { }} reached one + * directly and never went near {@code methodMissing}, so a tag library declaring a tag of the same + * name must not capture the call. Nothing here restricts a call that names its namespace, where + * the source has said which tag library it means. + */ + private static final Set RESERVED_NAMES = reservedNames(); + + private static final String REWRITTEN_MARKER = CompiledTagCallRewriter.class.getName(); + + private final SourceUnit sourceUnit; + private final TagLibraryIndex index; + private final ClassNode classNode; + private final String callerNamespace; + private final boolean page; + private final boolean rewritingPermitted; + private Set localNames = Collections.emptySet(); + /** + * How many closures enclose the expression being transformed. An unqualified call inside + * one may belong to the closure's delegate, which is only known when it runs. + */ + private int closureDepth; + private Set pageBindings = Collections.emptySet(); + private int rewritten; + + public CompiledTagCallRewriter(SourceUnit sourceUnit, TagLibraryIndex index, ClassNode classNode) { + this.sourceUnit = sourceUnit; + this.index = index; + this.classNode = classNode; + this.page = isGroovyPage(classNode); + // A page resolves a name against the model it was rendered with before it reaches a tag + // library, and that model is not visible here, so rewriting a page's tag call can only be + // sound where the page has given up dynamic resolution. Declaring compileStatic is that: it + // reserves the namespace names for tag libraries. A page that has not declared it keeps + // resolving its tags exactly as before. + this.rewritingPermitted = !this.page || isCompileStatic(classNode); + // An unqualified call is offered to the caller's own namespace before the default one, which is + // what a tag library declaring a namespace does at runtime. A page and a controller have no + // namespace of their own, so for them the two are the same. + String declared = this.page ? DEFAULT_NAMESPACE : TagLibraryAstDiscovery.resolveNamespace(classNode); + this.callerNamespace = declared != null ? declared : DEFAULT_NAMESPACE; + } + + /** + * @return how many calls were rewritten, for tests to assert against + */ + public int getRewrittenCount() { + return rewritten; + } + + public void rewrite() { + // A tag library is reached both as an artefact and as a class carrying the invoker trait, so + // rewriting can be asked for twice. Rewriting again would be harmless, but reporting a + // misspelled tag twice would not be. + if (classNode.getNodeMetaData(REWRITTEN_MARKER) != null) { + return; + } + classNode.putNodeMetaData(REWRITTEN_MARKER, Boolean.TRUE); + if (page) { + pageBindings = PageBindingCollector.collect(classNode); + } + for (MethodNode method : classNode.getMethods()) { + // getMethods() reaches inherited methods, whose bodies belong to the class that declared + // them. Rewriting one here would change a superclass through a subclass that happens to be + // able to call tags. Trait methods are woven as declarations on this class and so remain. + if (method.getDeclaringClass() != null && !classNode.equals(method.getDeclaringClass())) { + continue; + } + if (method.getCode() != null && !method.isAbstract()) { + rewriteBody(method.getCode(), method.getParameters()); + } + } + for (ConstructorNode constructor : classNode.getDeclaredConstructors()) { + if (constructor.getCode() != null) { + rewriteBody(constructor.getCode(), constructor.getParameters()); + } + } + for (FieldNode field : classNode.getFields()) { + if (field.getDeclaringClass() != null && !classNode.equals(field.getDeclaringClass())) { + continue; + } + Expression initial = field.getInitialExpression(); + if (initial != null) { + localNames = Collections.emptySet(); + field.setInitialValueExpression(transform(initial)); + } + } + for (Statement statement : classNode.getObjectInitializerStatements()) { + rewriteBody(statement, null); + } + } + + private void rewriteBody(Statement code, Parameter[] parameters) { + // An unqualified call reaches a tag only when nothing nearer answers to the name, and a local + // holding a closure answers to it. Which locals are in scope at a given point is not tracked + // here: a name declared anywhere in the body is treated as claimed throughout it, which can + // leave a call dispatched dynamically but never sends one to the wrong place. + localNames = LocalNameCollector.collect(code, parameters); + visitClassCodeContainer(code); + } + + @Override + protected SourceUnit getSourceUnit() { + return sourceUnit; + } + + @Override + public Expression transform(Expression expression) { + if (expression instanceof ClosureExpression closure) { + // ClassCodeExpressionTransformer deliberately does not descend into closures, and documents + // this override as the way to reach them. Without it a tag call written in a tag body, in a + // withFormat block, or in anything else taking a closure is never resolved - which is most + // of the tag calls in a real tag library. + // + // A call written with its namespace still says which tag library it means, so it is + // resolved here as anywhere else. One written without a namespace is not: a closure is + // given a delegate when it runs, and a name the delegate answers to is that delegate's, + // not a tag. request.withFormat { form multipartForm { } } is the case that proves it - + // form there is a format in a DSL, and rewriting it into g:form sends the call somewhere + // the author never wrote. + closureDepth++; + try { + closure.visit(this); + } + finally { + closureDepth--; + } + return closure; + } + if (expression instanceof MethodCallExpression call) { + Expression rewrite = rewriteTagCall(call); + if (rewrite != null) { + rewritten++; + return rewrite; + } + validateMarkupTagCall(call); + } + return super.transform(expression); + } + + private Expression rewriteTagCall(MethodCallExpression call) { + if (!(call.getMethod() instanceof ConstantExpression methodName) || + methodName.getValue() == null) { + return null; + } + String tagName = methodName.getValue().toString(); + String namespace = namespaceOf(call.getObjectExpression()); + if (namespace != null) { + // A namespace the build declared as filled in at runtime is left alone entirely: that + // declaration is how an application says its tags are decided while it runs, whether by a + // tag library registered then or by metaprogramming, and binding a call now would settle + // what it asked to keep open. + if (index.isDynamicNamespace(namespace)) { + return null; + } + if (!index.hasNamespace(namespace) || isShadowed(call.getObjectExpression(), namespace) || + pageBindings.contains(namespace)) { + return null; + } + if (!index.isKnown(namespace, tagName)) { + // Only where the name means a tag library for certain. In a page that has not given up + // dynamic resolution the receiver may just as well be the model it was rendered with, + // and reporting there would reject a call this release deliberately still allows. + if (this.rewritingPermitted) { + reportUnknownTag(namespace, tagName, call); + } + return null; + } + } + else { + namespace = unqualifiedNamespaceOf(call, tagName); + if (namespace == null) { + return null; + } + } + if (!this.rewritingPermitted) { + return null; + } + Expression invocation = invocation(namespace, tagName, call.getArguments()); + if (invocation != null) { + // Kept where the tag was written, so a stack trace and any later diagnostic still point at + // the line the author wrote rather than at the start of the file. + setSourcePosition(invocation, call); + } + return invocation; + } + + /** + * The namespace an unqualified call such as {@code message(code: 'x')} resolves in. + * + *

Only a name nothing else answers to reaches a tag at all: a real method of the class, an + * inherited one, a field, a property or a local wins, and whether such a member exists is what + * decides the call. Where nothing claims the name, dispatch offers it to the caller's own + * namespace and then to the default one, which is the order reproduced here. + * + * @return the namespace to invoke in, or {@code null} when the call is not resolvably a tag + */ + private String unqualifiedNamespaceOf(MethodCallExpression call, String tagName) { + if (page) { + // A page resolves an unqualified name against its binding before it reaches a tag, and what + // a page's binding holds - the model it was rendered with - is not visible here. A call + // written with its namespace says which tag library it means and is rewritten; one without + // is left to resolve as it did. + return null; + } + if (!index.rewritesUnqualifiedCalls()) { + // A bare name is only a tag when nothing nearer answers to it, and what answers to it is + // not fully knowable here: a method Groovy gives every object, a delegate the enclosing + // closure is handed at runtime, an overload the tag library also declares. Each of those + // is excluded below as far as the source shows it, but the compiler cannot see all of + // them, so this is off unless the build asks for it. + return null; + } + if (!call.isImplicitThis() || RESERVED_NAMES.contains(tagName)) { + return null; + } + if (closureDepth > 0) { + // Inside a closure the name may be answered by whatever delegate the closure is given. + return null; + } + if (declaresMember(tagName) || localNames.contains(tagName)) { + return null; + } + if (index.isDynamicNamespace(callerNamespace) || index.isDynamicNamespace(DEFAULT_NAMESPACE)) { + // The namespaces an unqualified call could reach were declared as decided at runtime. + return null; + } + if (index.isKnown(callerNamespace, tagName)) { + return callerNamespace; + } + if (index.isKnown(DEFAULT_NAMESPACE, tagName)) { + return DEFAULT_NAMESPACE; + } + // Not a tag this build knows about. It is not reported: an unqualified name in a controller is + // as likely to be a dynamic finder, an injected service method or anything else contributed at + // runtime as it is a misspelled tag. + return null; + } + + /** + * Builds the invocation, passing the attributes and body directly where the source says what they + * are and forwarding the arguments as written where it does not. + */ + private Expression invocation(String namespace, String tagName, Expression arguments) { + if (!(arguments instanceof TupleExpression tuple)) { + return null; + } + ArgumentListExpression invocationArgs = new ArgumentListExpression(); + invocationArgs.addExpression(new MethodCallExpression(new VariableExpression("this"), + LOOKUP_ACCESSOR, MethodCallExpression.NO_ARGUMENTS)); + invocationArgs.addExpression(new ConstantExpression(namespace)); + invocationArgs.addExpression(new ConstantExpression(tagName)); + + Expression[] attrsAndBody = attributesAndBody(tuple); + if (attrsAndBody != null) { + invocationArgs.addExpression(attrsAndBody[0]); + invocationArgs.addExpression(attrsAndBody[1]); + if (page) { + invocationArgs.addExpression(outputContext()); + } + return new StaticMethodCallExpression(INVOCATION_TYPE, INVOKE, invocationArgs); + } + + // The shape is only known once the arguments have been evaluated - a map held in a variable, a + // single value the tag reads under its own name, and so on - so they are forwarded as written + // and sorted out by the same rules the dynamic path applies. + // + // Only where those rules can actually sort them out. The invocation understands no arguments, + // one argument, and two whose first is a Map; anything else it reduces to a call with no + // attributes and no body, silently dropping what was written. A name can be both a tag and an + // ordinary overload - a tag foo(Map) beside a helper foo(String, String) - and such a call + // used to reach the overload. Forwarding it here would run the tag with nothing instead, so a + // shape this cannot account for is left to be dispatched as it was. + if (!forwardableShape(tuple)) { + return null; + } + if (page) { + invocationArgs.addExpression(outputContext()); + } + for (Expression argument : tuple.getExpressions()) { + invocationArgs.addExpression(transform(argument)); + } + return new StaticMethodCallExpression(INVOCATION_TYPE, + page ? INVOKE_ARGUMENTS_IN_CONTEXT : INVOKE_ARGUMENTS, invocationArgs); + } + + /** + * @param tuple the arguments as written + * @return whether forwarding them reaches the same tag the dynamic path would have reached + */ + private static boolean forwardableShape(TupleExpression tuple) { + List args = tuple.getExpressions(); + switch (args.size()) { + case 0: + case 1: + // Every one-argument shape is accounted for: a Map is the attributes, a Closure or a + // CharSequence is the body, anything else is a value read under the tag's own name. + return true; + case 2: + // Two arguments are attributes and a body only when the first really is a Map. Where + // that is not evident here it is not evident to the invocation either. + return args.get(0) instanceof MapExpression; + default: + return false; + } + } + + private Expression outputContext() { + return new MethodCallExpression(new VariableExpression("this"), OUTPUT_CONTEXT_ACCESSOR, + MethodCallExpression.NO_ARGUMENTS); + } + + /** + * @return the attributes and body to pass, or {@code null} when the source does not say what they + * are and the arguments have to be forwarded instead + */ + private Expression[] attributesAndBody(TupleExpression tuple) { + List args = tuple.getExpressions(); + Expression noAttributes = new MapExpression(); + Expression noBody = new ConstantExpression(null); + switch (args.size()) { + case 0: + return new Expression[] { noAttributes, noBody }; + case 1: + if (args.get(0) instanceof MapExpression) { + return new Expression[] { transform(args.get(0)), noBody }; + } + if (args.get(0) instanceof ClosureExpression) { + return new Expression[] { noAttributes, transform(args.get(0)) }; + } + return null; + case 2: + if (args.get(0) instanceof MapExpression && args.get(1) instanceof ClosureExpression) { + return new Expression[] { transform(args.get(0)), transform(args.get(1)) }; + } + return null; + default: + return null; + } + } + + /** + * Checks a tag written as markup, which a page compiles into a call naming the tag and namespace + * directly. Such a call is already an ordinary method call and needs no rewriting, but the names in + * it are worth the same check as the ones written in an expression. + */ + private void validateMarkupTagCall(MethodCallExpression call) { + if (!page || !MARKUP_TAG_CALL.equals(call.getMethodAsString()) || + !(call.getArguments() instanceof TupleExpression tuple) || + tuple.getExpressions().size() < 2) { + return; + } + if (!(tuple.getExpression(0) instanceof ConstantExpression tagName) || + !(tuple.getExpression(1) instanceof ConstantExpression namespace) || + tagName.getValue() == null || namespace.getValue() == null) { + return; + } + String namespaceName = namespace.getValue().toString(); + String tag = tagName.getValue().toString(); + if (index.isDynamicNamespace(namespaceName)) { + return; + } + if (index.hasNamespace(namespaceName) && !index.isKnown(namespaceName, tag)) { + reportUnknownTag(namespaceName, tag, call); + } + } + + /** + * Reports a tag that no compiled tag library declares. + * + *

Silent unless the build declared its tag libraries complete. A namespace holding some + * compiled tag libraries is not the same as one holding all of them: a plugin built before + * descriptors existed contributes tags to {@code g} without one, and a tag library registered + * while an application runs contributes more. Reporting by default would mean warning about calls + * that are perfectly correct - this framework calls one such tag itself - so a build says when it + * knows better. + */ + private void reportUnknownTag(String namespace, String tagName, Expression call) { + if (!index.isStrict() || index.isDynamicNamespace(namespace)) { + return; + } + if (!index.declaresNamespace(namespace)) { + // A namespace this project does not declare is filled in by tag libraries from elsewhere, + // and how many of them carry descriptors is not knowable here. Reporting a tag missing + // from such a namespace would fail a build over a plugin's perfectly good tag - which is + // what made strict checking unusable for g, the namespace it would matter most for. + return; + } + if (!index.isNamespaceComplete(namespace)) { + // Something contributing to this namespace could not be described. A tag missing from it + // is as likely to be one of those as a misspelling, and reporting it would fail a build + // over code that is correct. + return; + } + String message = "No such tag [" + tagName + "] in namespace [" + namespace + "]. Known tags: " + + String.join(", ", index.getTagNames(namespace)); + // Collected rather than fatal, so that every misspelling in a file is reported at once instead + // of one per build. + GrailsASTUtils.error(sourceUnit, call, message, false); + } + + /** + * @return the namespace a call is made through, or {@code null} when the receiver is not a plain + * name that could be one + */ + private static String namespaceOf(Expression objectExpression) { + if (objectExpression instanceof VariableExpression variable) { + return variable.isThisExpression() || variable.isSuperExpression() ? null : variable.getName(); + } + if (objectExpression instanceof PropertyExpression property && + property.getObjectExpression() instanceof VariableExpression receiver && + receiver.isThisExpression()) { + return property.getPropertyAsString(); + } + return null; + } + + /** + * Whether something in scope has already claimed the name, in which case it is that thing rather + * than a tag library namespace. + * + *

A namespace is not declared anywhere: it is reached because nothing else answers to the name. + * A local variable, a parameter or a field called {@code g} does answer to it, and rewriting such a + * call would silently send it to a tag library instead of the object the author wrote. + */ + private boolean isShadowed(Expression objectExpression, String namespace) { + if (objectExpression instanceof VariableExpression variable) { + Variable accessed = variable.getAccessedVariable(); + // A name that resolves to something - a local, a parameter, a field, a property - is that + // thing. Only a name nothing has claimed is left to mean a namespace. + if (accessed != null && !(accessed instanceof DynamicVariable)) { + return true; + } + } + // Reached as this.g, or as a bare name resolved dynamically: a field or property of that name + // anywhere in the hierarchy is the member, not a namespace. + return declaresProperty(namespace); + } + + /** + * Whether the class, or anything it inherits from, reads a property of this name. A method of the + * same name does not count: {@code g.link()} reads {@code g} as a property whatever methods exist. + */ + private boolean declaresProperty(String name) { + return classNode.getField(name) != null || + classNode.getProperty(name) != null || + hasGetter(name); + } + + /** + * Whether the class, or anything it inherits from, already answers to a name at all. An + * unqualified call reaches a tag only when nothing else does, so here a method counts too. + */ + private boolean declaresMember(String name) { + return declaresProperty(name) || !classNode.getMethods(name).isEmpty(); + } + + /** + * Whether a getter answers to the name. Groovy reads a property from {@code getX()} and, when the + * return type is boolean, from {@code isX()} as well, so both forms claim the name. + */ + private boolean hasGetter(String namespace) { + String capitalised = Character.toUpperCase(namespace.charAt(0)) + namespace.substring(1); + if (!classNode.getMethods("get" + capitalised).isEmpty()) { + return true; + } + for (MethodNode candidate : classNode.getMethods("is" + capitalised)) { + ClassNode returnType = candidate.getReturnType(); + if (returnType != null && (ClassHelper.isPrimitiveBoolean(returnType) || + ClassHelper.isWrapperBoolean(returnType))) { + return true; + } + } + return false; + } + + /** + * Collects the names an unqualified call must never be rewritten into a tag invocation for. + * + *

The metaclass of {@code Object} answers for every {@code DefaultGroovyMethods} method that + * applies to any receiver, and for every extension module registered on the classpath compiling + * this source, so asking it is the same question the runtime would have asked. Erring towards + * reserving a name costs an optimisation; failing to reserve one silently sends a call somewhere + * the author did not write. + */ + private static Set reservedNames() { + Set names = new HashSet<>(); + names.add("body"); + names.add("render"); + for (MetaMethod method : GroovySystem.getMetaClassRegistry().getMetaClass(Object.class).getMetaMethods()) { + names.add(method.getName()); + } + return Set.copyOf(names); + } + + /** + * Whether this class is a compiled GSP. Matched by name rather than by type so that rewriting tag + * calls in a page needs no dependency on the page runtime. + */ + private static boolean isGroovyPage(ClassNode classNode) { + for (ClassNode current = classNode.getSuperClass(); current != null; + current = current.getSuperClass()) { + if (GROOVY_PAGE_TYPE.equals(current.getName())) { + return true; + } + } + return false; + } + + /** + * Whether the class gave up dynamic resolution. A page compiled with {@code compileStatic="true"} + * carries the annotation, which is what makes the namespace names mean tag libraries there. + */ + private static boolean isCompileStatic(ClassNode classNode) { + for (AnnotationNode annotation : classNode.getAnnotations()) { + String name = annotation.getClassNode().getName(); + if (COMPILE_STATIC_TYPE.equals(name) || GRAILS_COMPILE_STATIC_TYPE.equals(name)) { + return true; + } + } + return false; + } +} diff --git a/grails-gsp/grails-web-taglib/src/main/groovy/grails/gsp/taglib/compiler/LocalNameCollector.java b/grails-gsp/grails-web-taglib/src/main/groovy/grails/gsp/taglib/compiler/LocalNameCollector.java new file mode 100644 index 00000000000..95adab3030c --- /dev/null +++ b/grails-gsp/grails-web-taglib/src/main/groovy/grails/gsp/taglib/compiler/LocalNameCollector.java @@ -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 grails.gsp.taglib.compiler; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import org.codehaus.groovy.ast.CodeVisitorSupport; +import org.codehaus.groovy.ast.Parameter; +import org.codehaus.groovy.ast.expr.ClosureExpression; +import org.codehaus.groovy.ast.expr.DeclarationExpression; +import org.codehaus.groovy.ast.expr.Expression; +import org.codehaus.groovy.ast.expr.TupleExpression; +import org.codehaus.groovy.ast.expr.VariableExpression; +import org.codehaus.groovy.ast.stmt.CatchStatement; +import org.codehaus.groovy.ast.stmt.ForStatement; +import org.codehaus.groovy.ast.stmt.Statement; + +/** + * Collects every name declared within a body: its parameters, its local variables, the parameters of + * the closures inside it, the variables its loops introduce and the names its catch blocks bind. + * + *

Used to decide whether an unqualified call such as {@code message(code: 'x')} could be reaching + * something local rather than a tag. Scope is not tracked, so a name declared anywhere in the body + * counts throughout it. That errs towards leaving a call to be dispatched dynamically, which is only + * a missed optimisation, rather than towards sending it somewhere the author did not write. + * + * @since 8.0.0 + */ +final class LocalNameCollector extends CodeVisitorSupport { + + /** + * The parameter a closure that names none still has. + */ + private static final String IMPLICIT_CLOSURE_PARAMETER = "it"; + + private final Set names = new HashSet<>(); + + private LocalNameCollector() { + } + + /** + * @param code the body to read, or {@code null} when there is none + * @param parameters the declaring method's parameters, or {@code null} when there are none + * @return every name declared within, never {@code null} + */ + static Set collect(Statement code, Parameter[] parameters) { + LocalNameCollector collector = new LocalNameCollector(); + collector.addParameters(parameters); + if (code != null) { + code.visit(collector); + } + return collector.names.isEmpty() ? Collections.emptySet() : collector.names; + } + + private void addParameters(Parameter[] parameters) { + if (parameters == null) { + return; + } + for (Parameter parameter : parameters) { + names.add(parameter.getName()); + } + } + + @Override + public void visitDeclarationExpression(DeclarationExpression expression) { + if (expression.isMultipleAssignmentDeclaration()) { + TupleExpression tuple = expression.getTupleExpression(); + for (Expression declared : tuple.getExpressions()) { + if (declared instanceof VariableExpression variable) { + names.add(variable.getName()); + } + } + } + else { + names.add(expression.getVariableExpression().getName()); + } + super.visitDeclarationExpression(expression); + } + + @Override + public void visitClosureExpression(ClosureExpression expression) { + if (expression.isParameterSpecified()) { + addParameters(expression.getParameters()); + } + else { + // A closure that names no parameter still has one, and a call to it is that parameter's + // method rather than a tag. + names.add(IMPLICIT_CLOSURE_PARAMETER); + } + super.visitClosureExpression(expression); + } + + @Override + public void visitForLoop(ForStatement forLoop) { + // A classic for carries both, an enhanced for only the value, so both are asked for. + addVariable(forLoop.getIndexVariable()); + addVariable(forLoop.getValueVariable()); + super.visitForLoop(forLoop); + } + + @Override + public void visitCatchStatement(CatchStatement statement) { + // CodeVisitorSupport visits the body but not the parameter the exception is caught into. + addVariable(statement.getVariable()); + super.visitCatchStatement(statement); + } + + private void addVariable(Parameter parameter) { + if (parameter != null) { + names.add(parameter.getName()); + } + } +} diff --git a/grails-gsp/grails-web-taglib/src/main/groovy/grails/gsp/taglib/compiler/PageBindingCollector.java b/grails-gsp/grails-web-taglib/src/main/groovy/grails/gsp/taglib/compiler/PageBindingCollector.java new file mode 100644 index 00000000000..bed864678be --- /dev/null +++ b/grails-gsp/grails-web-taglib/src/main/groovy/grails/gsp/taglib/compiler/PageBindingCollector.java @@ -0,0 +1,93 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package grails.gsp.taglib.compiler; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import org.codehaus.groovy.ast.ClassNode; +import org.codehaus.groovy.ast.CodeVisitorSupport; +import org.codehaus.groovy.ast.MethodNode; +import org.codehaus.groovy.ast.expr.ConstantExpression; +import org.codehaus.groovy.ast.expr.Expression; +import org.codehaus.groovy.ast.expr.MapEntryExpression; +import org.codehaus.groovy.ast.expr.MapExpression; +import org.codehaus.groovy.ast.expr.MethodCallExpression; +import org.codehaus.groovy.ast.expr.TupleExpression; + +/** + * Collects the names a page puts into its own binding with {@code }. + * + *

A page resolves a name against its binding before anything else, so a page that sets a variable + * named after a tag library namespace means that variable rather than the namespace. The model a page + * is rendered with is not visible when it is compiled, but what the page itself sets is: it compiles + * into a call naming the tag and its attributes. + * + * @since 8.0.0 + */ +final class PageBindingCollector extends CodeVisitorSupport { + + private static final String MARKUP_TAG_CALL = "invokeTag"; + private static final String SET_TAG = "set"; + private static final String VAR_ATTRIBUTE = "var"; + + private final Set names = new HashSet<>(); + + private PageBindingCollector() { + } + + /** + * @param classNode the compiled page + * @return the names the page sets, never {@code null} + */ + static Set collect(ClassNode classNode) { + PageBindingCollector collector = new PageBindingCollector(); + for (MethodNode method : classNode.getMethods()) { + if (method.getCode() != null) { + method.getCode().visit(collector); + } + } + return collector.names.isEmpty() ? Collections.emptySet() : collector.names; + } + + @Override + public void visitMethodCallExpression(MethodCallExpression call) { + if (MARKUP_TAG_CALL.equals(call.getMethodAsString()) && + call.getArguments() instanceof TupleExpression tuple && + tuple.getExpressions().size() > 3 && + tuple.getExpression(0) instanceof ConstantExpression tagName && + SET_TAG.equals(tagName.getValue()) && + tuple.getExpression(3) instanceof MapExpression attrs) { + addVariableName(attrs); + } + super.visitMethodCallExpression(call); + } + + private void addVariableName(MapExpression attrs) { + for (MapEntryExpression entry : attrs.getMapEntryExpressions()) { + Expression key = entry.getKeyExpression(); + Expression value = entry.getValueExpression(); + if (key instanceof ConstantExpression name && VAR_ATTRIBUTE.equals(name.getValue()) && + value instanceof ConstantExpression variable && variable.getValue() != null) { + this.names.add(variable.getValue().toString()); + } + } + } +} diff --git a/grails-gsp/grails-web-taglib/src/main/groovy/grails/gsp/taglib/compiler/TagLibArtefactTypeAstTransformation.java b/grails-gsp/grails-web-taglib/src/main/groovy/grails/gsp/taglib/compiler/TagLibArtefactTypeAstTransformation.java index 067567bbffd..a661ad8a545 100644 --- a/grails-gsp/grails-web-taglib/src/main/groovy/grails/gsp/taglib/compiler/TagLibArtefactTypeAstTransformation.java +++ b/grails-gsp/grails-web-taglib/src/main/groovy/grails/gsp/taglib/compiler/TagLibArtefactTypeAstTransformation.java @@ -19,6 +19,9 @@ package grails.gsp.taglib.compiler; +import java.io.File; +import java.io.IOException; + import groovy.lang.Closure; import org.codehaus.groovy.ast.AnnotationNode; import org.codehaus.groovy.ast.ClassNode; @@ -29,6 +32,10 @@ import grails.gsp.TagLib; import org.grails.compiler.injection.ArtefactTypeAstTransformation; +import org.grails.compiler.injection.GrailsASTUtils; +import org.grails.taglib.discovery.TagLibraryAstDiscovery; +import org.grails.taglib.index.TagLibraryIndex; +import org.grails.taglib.index.TagLibraryIndexWriter; @GroovyASTTransformation(phase = CompilePhase.CANONICALIZATION) public class TagLibArtefactTypeAstTransformation extends ArtefactTypeAstTransformation { @@ -45,9 +52,84 @@ public class TagLibArtefactTypeAstTransformation extends ArtefactTypeAstTransfor @Override protected String resolveArtefactType(SourceUnit sourceUnit, AnnotationNode annotationNode, ClassNode classNode) { addClosureTagDeprecationWarnings(sourceUnit, classNode); + writeIndexEntry(sourceUnit, classNode); + rewriteResolvedTagCalls(sourceUnit, classNode); return "TagLibrary"; } + /** + * Records the namespace and tag names this tag library declares, so that a GSP compiled later can + * resolve a tag call without loading the tag library or consulting its metaclass. + * + *

Failure to write is never fatal: the index is an optimisation, and a missing descriptor + * degrades to the runtime resolution that applies when a tag library is registered dynamically. + */ + protected void writeIndexEntry(SourceUnit sourceUnit, ClassNode classNode) { + if (classNode.isAbstract() || classNode.isInterface()) { + // Artefact handling never registers an abstract class, so describing one would record + // tags that nothing answers to at runtime. + return; + } + File targetDirectory = sourceUnit.getConfiguration() != null ? + sourceUnit.getConfiguration().getTargetDirectory() : + null; + if (targetDirectory == null) { + // In-memory compilation, as used by GSP unit tests and the shell, has nowhere to put the + // descriptor; those callers resolve tags at runtime. + return; + } + if (buildOwnsIndex(sourceUnit)) { + // The build writes the index itself, reading the source of anything a tag library refers + // to so that it can describe all of them. Writing a copy here as well would put a second + // index into the class output, competing with that one for the same path when packaged, + // and nothing would remove it when this tag library was renamed or deleted: an incremental + // compilation does not revisit a source that has not changed, so it would simply stay. + return; + } + String namespace = TagLibraryAstDiscovery.resolveNamespace(classNode); + if (namespace == null) { + // The namespace is only known once the tag library's initialiser runs, so recording the + // tags would file them under the wrong namespace. Leave them to runtime resolution. + return; + } + // Runtime only treats a parameter as attrs or body when it carries that name, unless the class + // was compiled without parameter names, in which case any name is accepted. The same + // compilation setting therefore decides which methods are dispatchable. + boolean parameterNamesRetained = sourceUnit.getConfiguration().getParameters(); + try { + TagLibraryIndexWriter.write(targetDirectory, classNode.getName(), namespace, + TagLibraryAstDiscovery.findTags(classNode, parameterNamesRetained)); + } catch (IOException | RuntimeException e) { + GrailsASTUtils.warning(sourceUnit, classNode, + "Could not write the tag library index entry for [" + classNode.getName() + "]: " + + e.getMessage() + ". Tags in this library will be resolved at runtime."); + } + } + + /** + * Whether the build writes the index itself, which the Grails Gradle plugin does and signals by + * putting the settings it declared on the compile classpath. + * + *

Where no build does - compiling outside the Grails Gradle plugin, as this framework's own + * build and a plain Groovy compilation do - each tag library describes itself as it compiles. + */ + private static boolean buildOwnsIndex(SourceUnit sourceUnit) { + ClassLoader classLoader = sourceUnit.getClassLoader(); + return classLoader != null && classLoader.getResource(TagLibraryIndex.SETTINGS_LOCATION) != null; + } + + /** + * Replaces calls to tags this build already knows about with direct invocations, leaving anything + * it cannot resolve to be dispatched as before. + */ + protected void rewriteResolvedTagCalls(SourceUnit sourceUnit, ClassNode classNode) { + TagLibraryIndex index = TagLibraryIndex.forClassLoader(sourceUnit.getClassLoader()); + if (index.isEmpty()) { + return; + } + new CompiledTagCallRewriter(sourceUnit, index, classNode).rewrite(); + } + @Override protected ClassNode getAnnotationType() { return MY_TYPE; @@ -65,8 +147,10 @@ protected void addClosureTagDeprecationWarnings(SourceUnit sourceUnit, ClassNode continue; } if (field.getType() != null && CLOSURE_TYPE.equals(field.getType())) { - String message = "Closure-based tag definition [" + field.getName() + "] in TagLib [" + classNode.getName() + "] is deprecated. " + - "Define tag handlers as methods instead."; + String message = "Closure-based tag definition [" + field.getName() + "] in TagLib [" + + classNode.getName() + "] is deprecated and is not resolved when a page is " + + "compiled, so calls to it stay dynamic. Define the tag as a method instead: " + + "def " + field.getName() + "(Map attrs) { ... }"; org.grails.compiler.injection.GrailsASTUtils.warning(sourceUnit, field, message); } } diff --git a/grails-gsp/grails-web-taglib/src/test/groovy/org/grails/taglib/discovery/TagDiscoveryRulesSpec.groovy b/grails-gsp/grails-web-taglib/src/test/groovy/org/grails/taglib/discovery/TagDiscoveryRulesSpec.groovy new file mode 100644 index 00000000000..27442d87a97 --- /dev/null +++ b/grails-gsp/grails-web-taglib/src/test/groovy/org/grails/taglib/discovery/TagDiscoveryRulesSpec.groovy @@ -0,0 +1,122 @@ +/* + * 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.discovery + +import java.lang.reflect.Method + +import org.codehaus.groovy.ast.ClassNode +import org.codehaus.groovy.ast.MethodNode +import org.codehaus.groovy.control.CompilationUnit +import org.codehaus.groovy.control.CompilerConfiguration +import org.codehaus.groovy.control.Phases +import org.codehaus.groovy.control.SourceUnit +import spock.lang.Specification +import spock.lang.Unroll + +/** + * Runs one matrix of method shapes through both views of the discovery rules. + * + *

Each case is compiled once and classified twice: from the syntax tree, as a build does while a + * tag library is compiled, and by reflection over the resulting class, as an application does at + * startup. Both must reach the stated answer. A case where they differ is a tag that either compiles + * and then fails to dispatch, or is reported as unknown while being perfectly callable. + */ +class TagDiscoveryRulesSpec extends Specification { + + @Unroll + void 'the tree and the compiled class agree that #description'() { + given: 'a tag library declaring the method under test' + String source = """ + import grails.gsp.Tag + import grails.gsp.NotATag + class Subject { + ${declaration} + } + """ + + when: 'it is classified from the syntax tree' + boolean fromTree = classifyFromTree(source, methodName) + + and: 'and by reflection over the compiled class' + boolean fromClass = classifyFromClass(source, methodName) + + then: 'both views agree' + fromTree == fromClass + + and: 'on the expected answer' + fromTree == isTag + + where: + description | methodName | isTag | declaration + 'a Map attrs parameter is a tag' | 'plain' | true | 'def plain(Map attrs) { }' + 'attrs plus a Closure body is a tag' | 'withBody' | true | 'def withBody(Map attrs, Closure body) { }' + 'a Closure body alone is a tag' | 'bodyOnly' | true | 'def bodyOnly(Closure body) { }' + 'a Map named something else is not a tag' | 'renamed' | false | 'def renamed(Map options) { }' + 'a Closure named something else is not a tag' | 'renamedBody' | false | 'def renamedBody(Map attrs, Closure block) { }' + 'an untyped attrs parameter is not a tag' | 'untyped' | false | 'def untyped(attrs) { }' + 'a no argument method is not a tag' | 'nullary' | false | 'def nullary() { }' + 'an unrelated helper is not a tag' | 'helper' | false | 'String helper(String a, int b) { a }' + 'a static method is not a tag' | 'statik' | false | 'static def statik(Map attrs) { }' + 'a private method is not a tag' | 'hidden' | false | 'private def hidden(Map attrs) { }' + 'a getter is not a tag' | 'getThing' | false | 'def getThing() { }' + 'a setter is not a tag' | 'setThing' | false | 'void setThing(Map attrs) { }' + 'NotATag excludes a conventional shape' | 'excluded' | false | '@NotATag def excluded(Map attrs) { }' + 'Tag includes an unconventional shape' | 'annotated' | true | '@Tag def annotated(Map attrs, String code) { code }' + 'a framework trait name is not a tag' | 'withCodec' | false | 'def withCodec(Map attrs) { }' + 'a defaulted trailing parameter is a tag' | 'defaulted' | true | 'def defaulted(Map attrs, String extra = null) { }' + // These three pin the differences between these rules and the discovery they replaced, which + // excluded Object and GroovyObject members by signature rather than by name, checked that an + // is* accessor returned boolean, and said nothing about $ in a name. None of them is reachable + // by a tag that would otherwise have been discovered, and the shape check rejects them anyway - + // but discovery is what a running application registers tag libraries from, so the answers are + // stated here rather than left to be derived. + 'an Object member name is not a tag' | 'equals' | false | 'def equals(Map attrs) { }' + 'a non boolean is accessor is not a tag' | 'isThing' | false | 'String isThing() { null }' + 'a synthetic name is not a tag' | 'a$b' | false | "def 'a\$b'(Map attrs) { }" + } + + /** + * Classifies straight from the tree, which is what the tag library index generation sees. + */ + private boolean classifyFromTree(String source, String methodName) { + CompilerConfiguration configuration = new CompilerConfiguration() + configuration.parameters = true + CompilationUnit unit = new CompilationUnit(configuration) + unit.addSource(SourceUnit.create('Subject.groovy', source)) + unit.compile(Phases.CANONICALIZATION) + ClassNode classNode = unit.firstClassNode + MethodNode method = classNode.methods.find { it.name == methodName } + assert method != null, "no method [${methodName}] on the tree" + TagDiscoveryRules.isTagMethod(new AstTagMethodView(method, configuration.parameters)) + } + + /** + * Classifies the compiled class, which is what an application does when it registers tag libraries. + */ + private boolean classifyFromClass(String source, String methodName) { + CompilerConfiguration configuration = new CompilerConfiguration() + configuration.parameters = true + def loader = new GroovyClassLoader(getClass().classLoader, configuration) + Class compiled = loader.parseClass(source, 'Subject.groovy') + // Groovy expands a parameter default into overloads, so the shortest form is the callable one. + List candidates = compiled.declaredMethods.findAll { it.name == methodName } + assert candidates, "no method [${methodName}] on the compiled class" + candidates.any { TagDiscoveryRules.isTagMethod(new ReflectedTagMethodView(it)) } + } +} diff --git a/grails-gsp/grails-web-taglib/src/test/groovy/org/grails/taglib/discovery/TagSetAgreementSpec.groovy b/grails-gsp/grails-web-taglib/src/test/groovy/org/grails/taglib/discovery/TagSetAgreementSpec.groovy new file mode 100644 index 00000000000..851b4649b37 --- /dev/null +++ b/grails-gsp/grails-web-taglib/src/test/groovy/org/grails/taglib/discovery/TagSetAgreementSpec.groovy @@ -0,0 +1,98 @@ +/* + * 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.discovery + +import org.codehaus.groovy.ast.ClassNode +import org.codehaus.groovy.control.CompilationUnit +import org.codehaus.groovy.control.CompilerConfiguration +import org.codehaus.groovy.control.Phases +import org.codehaus.groovy.control.SourceUnit +import spock.lang.Specification +import spock.lang.Unroll + +/** + * The set of tags a build records and the set an application registers have to be the same set. + * + *

{@link TagDiscoveryRulesSpec} pins whether a given method is a tag. This pins the other half: + * which members are asked about at all, and how far up the hierarchy. That half used to be written + * once per side, and the two sides disagreed - a {@code Closure} tag inherited from a base class was + * registered at runtime and missing from the index, so a namespace could be reported complete while + * a working tag was unknown, which under strict checking fails a build over correct code. + */ +class TagSetAgreementSpec extends Specification { + + @Unroll + void 'both views find the same tags when #description'() { + when: + Set fromTree = fromTree(source, subject) + + and: + Set fromClass = fromClass(source, subject) + + then: 'neither side may know a tag the other does not' + fromTree == fromClass + + and: + fromTree == expected as Set + + where: + description | subject | expected | source + 'a method tag is declared' | 'Subject' | ['plain'] | 'class Subject { def plain(Map attrs) { } }' + 'a closure tag is declared' | 'Subject' | ['legacy'] | 'class Subject { Closure legacy = { Map attrs -> } }' + 'both kinds are declared' | 'Subject' | ['plain', 'legacy'] | 'class Subject { def plain(Map attrs) { }\n Closure legacy = { Map attrs -> } }' + 'a closure tag is inherited' | 'Subject' | ['common'] | 'class BaseOne { Closure common = { Map attrs -> } }\nclass Subject extends BaseOne { }' + 'a closure tag is inherited twice' | 'Subject' | ['common'] | 'class TopTwo { Closure common = { Map attrs -> } }\nclass MidTwo extends TopTwo { }\nclass Subject extends MidTwo { }' + 'a subclass redeclares a closure' | 'Subject' | ['common'] | 'class BaseThree { Closure common = { Map attrs -> } }\nclass Subject extends BaseThree { Closure common = { Map attrs -> } }' + 'a method tag is inherited' | 'Subject' | [] | 'class BaseFour { def plain(Map attrs) { } }\nclass Subject extends BaseFour { }' + 'a closure field is not a tag shape' | 'Subject' | ['odd'] | 'class Subject { Closure odd = { String a, int b -> } }' + 'a static closure is not a tag' | 'Subject' | [] | 'class Subject { static Closure notATag = { Map attrs -> } }' + } + + void 'an inherited closure tag is kept alongside the class own tags, not lost'() { + when: + Set tags = fromTree(''' + class BaseFive { Closure common = { Map attrs -> } } + class Subject extends BaseFive { def own(Map attrs) { } } + ''', 'Subject') + + then: 'which is what the runtime registers, so the index may not omit it' + tags == ['own', 'common'] as Set + } + + private Set fromTree(String source, String subject) { + CompilerConfiguration configuration = new CompilerConfiguration() + configuration.parameters = true + CompilationUnit unit = new CompilationUnit(configuration) + unit.addSource(SourceUnit.create('Subject.groovy', source)) + unit.compile(Phases.CANONICALIZATION) + ClassNode classNode = unit.AST.classes.find { it.nameWithoutPackage == subject } + assert classNode != null, "no class [${subject}] on the tree" + TagDiscoveryRules.findTags(new AstTagLibraryView(classNode, configuration.parameters)) + } + + private Set fromClass(String source, String subject) { + CompilerConfiguration configuration = new CompilerConfiguration() + configuration.parameters = true + GroovyClassLoader loader = new GroovyClassLoader(getClass().classLoader, configuration) + Class compiled = null + loader.parseClass(source, 'Subject.groovy') + compiled = loader.loadClass(subject) + TagDiscoveryRules.findTags(new ReflectedTagLibraryView(compiled)) + } +} diff --git a/grails-gsp/grails-web-taglib/src/test/groovy/org/grails/taglib/index/SingleIndexProducerSpec.groovy b/grails-gsp/grails-web-taglib/src/test/groovy/org/grails/taglib/index/SingleIndexProducerSpec.groovy new file mode 100644 index 00000000000..34ac938f6ae --- /dev/null +++ b/grails-gsp/grails-web-taglib/src/test/groovy/org/grails/taglib/index/SingleIndexProducerSpec.groovy @@ -0,0 +1,126 @@ +/* + * 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.index + +import java.nio.file.Files +import java.nio.file.Path + +import org.codehaus.groovy.control.CompilationUnit +import org.codehaus.groovy.control.CompilerConfiguration +import spock.lang.Specification +import spock.lang.TempDir + +/** + * Two producers writing the index would put descriptors both in the directory the build generates and + * in the class output. Both reach the classpath and both are packaged, so a tag library renamed or + * deleted between builds could keep being described by the copy written class by class, which nothing + * cleans. There is one producer per build. + */ +class SingleIndexProducerSpec extends Specification { + + private static final String TAG_LIB = ''' + import grails.gsp.TagLib + @TagLib + class ProducerCheckTagLib { + static namespace = 'producercheck' + def hello(Map attrs) { } + } + ''' + + @TempDir + Path tempDir + + void 'a tag library describes itself when nothing else has'() { + given: 'compiling outside the Grails Gradle plugin, as a plain Groovy compilation does' + Path output = compile(false) + + expect: + descriptor(output, 'ProducerCheckTagLib').isFile() + manifest(output).isFile() + } + + void 'a tag library writes no descriptor when the build writes the index'() { + given: 'the build described it from source before compiling it' + Path output = compile(true) + + expect: 'nothing is written into the class output to be merged with it or packaged beside it' + !descriptor(output, 'ProducerCheckTagLib').isFile() + !manifest(output).isFile() + } + + void 'nothing is written even for a tag library the index on the classpath does not name'() { + given: 'a build that writes the index reads the source of what a tag library refers to, so it' + Path output = compileWithIndexDescribing('some.other.TagLib', 'other', 'somethingElse') + + expect: 'describes all of them, and a copy here could only go stale beside it' + !descriptor(output, 'ProducerCheckTagLib').isFile() + } + + private static File descriptor(Path output, String className) { + output.resolve(TagLibraryIndex.INDEX_LOCATION + className + '.properties').toFile() + } + + private static File manifest(Path output) { + output.resolve(TagLibraryIndex.INDEX_LOCATION + 'index.properties').toFile() + } + + /** + * @param described whether the build already described this tag library + * @return the class output directory + */ + private Path compile(boolean described) { + described ? compileWithIndexDescribing('ProducerCheckTagLib', 'producercheck', 'hello') : + compileAgainst(null, 'none') + } + + /** + * Compiles against a generated index that describes the given tag library, which is how the build + * presents what it managed to describe before compilation. + */ + private Path compileWithIndexDescribing(String className, String namespace, String tag) { + Path generated = Files.createDirectories(tempDir.resolve('generated-' + className)) + Path indexDir = Files.createDirectories(generated.resolve(TagLibraryIndex.INDEX_LOCATION)) + indexDir.resolve('compile-settings.properties').toFile().text = 'strictTags=false\n' + indexDir.resolve('index.properties').toFile().text = "${className}=\n" + indexDir.resolve(className + '.properties').toFile().text = + "version=${TagLibraryIndex.FORMAT_VERSION}\nclass=${className}\n" + + "namespace=${namespace}\ntags=${tag}\n" + compileAgainst(generated, className) + } + + private Path compileAgainst(Path generatedIndex, String label) { + Path sourceFile = tempDir.resolve('ProducerCheckTagLib.groovy') + sourceFile.toFile().text = TAG_LIB + Path outputDir = Files.createDirectories(tempDir.resolve('classes-' + label)) + + ClassLoader parent = generatedIndex != null ? + new URLClassLoader([generatedIndex.toUri().toURL()] as URL[], getClass().classLoader) : + getClass().classLoader + + CompilerConfiguration configuration = new CompilerConfiguration() + configuration.targetDirectory = outputDir.toFile() + configuration.parameters = true + CompilationUnit unit = new CompilationUnit(configuration, null, + new GroovyClassLoader(parent, configuration)) + unit.addSource(sourceFile.toFile()) + unit.compile() + + outputDir + } +} diff --git a/grails-gsp/grails-web-taglib/src/test/groovy/org/grails/taglib/index/SourceResolvedIndexGeneratorSpec.groovy b/grails-gsp/grails-web-taglib/src/test/groovy/org/grails/taglib/index/SourceResolvedIndexGeneratorSpec.groovy new file mode 100644 index 00000000000..60653a61a44 --- /dev/null +++ b/grails-gsp/grails-web-taglib/src/test/groovy/org/grails/taglib/index/SourceResolvedIndexGeneratorSpec.groovy @@ -0,0 +1,452 @@ +/* + * 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.index + +import java.nio.file.Files +import java.nio.file.Path + +import spock.lang.Specification +import spock.lang.TempDir + +/** + * A tag library commonly refers to something the same project declares, and none of those exist as + * classes when the index is generated. Their source is compiled alongside it instead. + * + *

What is missing decides what a tag library declares: a base class carries the namespace, a trait + * carries tags, and a parameter type decides whether a method is a tag at all. Answering with a + * stand-in would file a tag library under the wrong namespace or leave out tags the running + * application has, so what these check is that the answer is read rather than guessed - and that a + * name which is simply wrong still fails. + */ +class SourceResolvedIndexGeneratorSpec extends Specification { + + @TempDir + Path tempDir + + Path taglibs + Path app + Path output + + def setup() { + taglibs = Files.createDirectories(tempDir.resolve('grails-app/taglib')) + app = Files.createDirectories(tempDir.resolve('src/main/groovy')) + output = Files.createDirectories(tempDir.resolve('out')) + } + + void 'a tag library injecting a service this project declares is described'() { + given: + appSource('com/example/BookService.groovy', ''' + package com.example + class BookService { + List list() { [] } + } + ''') + taglib('Injecting.groovy', ''' + import com.example.BookService + import grails.gsp.TagLib + @TagLib + class InjectingTagLib { + static namespace = 'injecting' + BookService bookService + def listBooks(Map attrs) { } + } + ''') + + when: + generate() + + then: + descriptor('InjectingTagLib').namespace == 'injecting' + descriptor('InjectingTagLib').tags == 'listBooks' + } + + void 'a namespace inherited from a base class this project declares is read, not guessed'() { + given: 'guessing would file it under the default namespace, where its tags do not exist' + appSource('com/example/BaseTagLib.groovy', ''' + package com.example + class BaseTagLib { + static namespace = 'inherited' + } + ''') + taglib('Child.groovy', ''' + import com.example.BaseTagLib + import grails.gsp.TagLib + @TagLib + class ChildTagLib extends BaseTagLib { + def greet(Map attrs) { } + } + ''') + + when: + generate() + + then: + descriptor('ChildTagLib').namespace == 'inherited' + } + + void 'tags a trait this project declares contributes are described'() { + given: 'a stand-in would prevent the trait being applied, losing tags the application has' + appSource('com/example/GreetingTags.groovy', ''' + package com.example + trait GreetingTags { + def hello(Map attrs) { } + } + ''') + taglib('Carrying.groovy', ''' + import com.example.GreetingTags + import grails.gsp.TagLib + @TagLib + class CarryingTagLib implements GreetingTags { + static namespace = 'carrying' + def goodbye(Map attrs) { } + } + ''') + + when: + generate() + + then: + descriptor('CarryingTagLib').tags.split(',').toList().sort() == + ['goodbye', 'hello'] + } + + void 'a parameter type this project declares is recognised as attributes when it is a Map'() { + given: 'runtime asks whether the type is assignable, so the index has to ask the same' + appSource('com/example/Attrs.groovy', ''' + package com.example + class Attrs extends LinkedHashMap { + } + ''') + taglib('Subtyped.groovy', ''' + import com.example.Attrs + import grails.gsp.TagLib + @TagLib + class SubtypedTagLib { + static namespace = 'subtyped' + def show(Attrs attrs) { } + } + ''') + + when: + generate() + + then: + descriptor('SubtypedTagLib').tags == 'show' + } + + void 'a star import resolves to the type that exists rather than the first one tried'() { + given: 'answering the first missing candidate would stop the search before the real one' + appSource('com/example/present/Helper.groovy', ''' + package com.example.present + class Helper { + static String help() { 'helped' } + } + ''') + taglib('Starred.groovy', ''' + import com.example.absent.* + import com.example.present.* + import grails.gsp.TagLib + @TagLib + class StarredTagLib { + static namespace = 'starred' + def show(Map attrs) { Helper.help() } + } + ''') + + when: + generate() + + then: + descriptor('StarredTagLib').namespace == 'starred' + descriptor('StarredTagLib').tags == 'show' + } + + void 'a misspelled type is not invented, and the tag library referring to it is left out'() { + given: 'inventing it would let a description be derived from a tree that does not compile' + taglib('Misspelled.groovy', ''' + import com.example.NoSuchService + import grails.gsp.TagLib + @TagLib + class MisspelledTagLib { + static namespace = 'misspelled' + NoSuchService service + def show(Map attrs) { } + } + ''') + taglib('Fine.groovy', ''' + import grails.gsp.TagLib + @TagLib + class FineTagLib { + static namespace = 'fine' + def show(Map attrs) { } + } + ''') + + when: + generate() + + then: 'and the one beside it is still described' + manifest() == ['FineTagLib'] + } + + void 'a tag library referring to a source of this project that does not compile is left out'() { + given: + appSource('com/example/Broken.groovy', ''' + package com.example + class Broken { + def oops( { + } + ''') + taglib('Referring.groovy', ''' + import com.example.Broken + import grails.gsp.TagLib + @TagLib + class ReferringTagLib { + static namespace = 'referring' + Broken broken + def show(Map attrs) { } + } + ''') + taglib('Unaffected.groovy', ''' + import grails.gsp.TagLib + @TagLib + class UnaffectedTagLib { + static namespace = 'unaffected' + def show(Map attrs) { } + } + ''') + + when: + generate() + + then: 'one unreadable source costs its own tag library, not the whole index' + manifest() == ['UnaffectedTagLib'] + } + + void 'a namespace whose tag library could not be read is recorded as incomplete'() { + given: 'so that a call to one of its tags is never reported as a misspelling' + taglib('Unreadable.groovy', ''' + import com.example.NoSuchService + import grails.gsp.TagLib + @TagLib + class UnreadableTagLib { + static namespace = 'partial' + NoSuchService service + def show(Map attrs) { } + } + ''') + taglib('Sibling.groovy', ''' + import grails.gsp.TagLib + @TagLib + class SiblingTagLib { + static namespace = 'partial' + def other(Map attrs) { } + } + ''') + + when: + generate() + + then: 'the namespace exists, but is known to be missing some of its tags' + indexOf().hasNamespace('partial') + !indexOf().isNamespaceComplete('partial') + } + + void 'nothing is recorded as incomplete when everything could be read'() { + given: + taglib('Whole.groovy', ''' + import grails.gsp.TagLib + @TagLib + class WholeTagLib { + static namespace = 'whole' + def show(Map attrs) { } + } + ''') + + when: + generate() + + then: + indexOf().isNamespaceComplete('whole') + indexOf().incompleteNamespaces.isEmpty() + } + + void 'a tag library whose namespace cannot even be read leaves nothing complete'() { + given: 'what was missed cannot be attributed, so no namespace may be treated as complete' + taglib('Nameless.groovy', ''' + import com.example.NoSuchService + import grails.gsp.TagLib + @TagLib + class NamelessTagLib { + NoSuchService service + def show(Map attrs) { } + } + ''') + taglib('Other.groovy', ''' + import grails.gsp.TagLib + @TagLib + class OtherTagLib { + static namespace = 'other' + def show(Map attrs) { } + } + ''') + + when: + generate() + + then: + !indexOf().isNamespaceComplete('other') + } + + void 'a namespace named in a comment is not mistaken for the declaration'() { + given: 'taking the comment would leave the real namespace looking complete, so a call to one' + taglib('Commented.groovy', ''' + import com.example.NoSuchService + import grails.gsp.TagLib + @TagLib + class CommentedTagLib { + // static namespace = 'decoy' + static namespace = 'actual' + NoSuchService service + def show(Map attrs) { } + } + ''') + + when: + generate() + + then: 'of its tags that does exist would be reported as one that does not' + !indexOf().isNamespaceComplete('actual') + indexOf().isNamespaceComplete('decoy') + } + + void 'a namespace field on some other class in the file is not the tag library\'s'() { + given: 'claiming it would leave the namespace the tag library is really in looking complete' + taglib('Neighboured.groovy', ''' + import com.example.NoSuchService + import grails.gsp.TagLib + + class Helper { + static namespace = 'decoy' + } + + @TagLib + class NeighbouredTagLib { + NoSuchService service + def show(Map attrs) { } + } + ''') + + when: + generate() + + then: 'the tag library declares none of its own, so nothing is complete' + !indexOf().isNamespaceComplete('g') + !indexOf().isNamespaceComplete('decoy') + } + + void 'a file holding more than one tag library claims neither namespace'() { + given: 'which of them could not be read is not knowable' + taglib('Pair.groovy', ''' + import com.example.NoSuchService + import grails.gsp.TagLib + + @TagLib + class FirstPairTagLib { + static namespace = 'first' + NoSuchService service + def show(Map attrs) { } + } + + @TagLib + class SecondPairTagLib { + static namespace = 'second' + def show(Map attrs) { } + } + ''') + + when: + generate() + + then: + !indexOf().isNamespaceComplete('first') + !indexOf().isNamespaceComplete('second') + } + + void 'a skipped tag library in the default namespace leaves that namespace incomplete'() { + given: 'it states no namespace, so the one it is in cannot be claimed from the source alone' + taglib('Defaulted.groovy', ''' + import com.example.NoSuchService + import grails.gsp.TagLib + + @TagLib + class DefaultedTagLib { + NoSuchService service + def show(Map attrs) { } + } + ''') + + when: + generate() + + then: + !indexOf().isNamespaceComplete('g') + } + + private TagLibraryIndex indexOf() { + URLClassLoader loader = new URLClassLoader([output.toUri().toURL()] as URL[], (ClassLoader) null) + try { + return TagLibraryIndex.load(loader) + } + finally { + loader.close() + } + } + + private void generate() { + TagLibraryIndexGenerator.generate([taglibs.toFile()], [app.toFile()], output.toFile(), + true, 'UTF-8') + } + + private void taglib(String name, String source) { + taglibs.resolve(name).toFile().text = source + } + + private void appSource(String relativePath, String source) { + Path file = app.resolve(relativePath) + Files.createDirectories(file.parent) + file.toFile().text = source + } + + private List manifest() { + Properties names = new Properties() + File file = output.resolve(TagLibraryIndex.INDEX_LOCATION + 'index.properties').toFile() + if (file.isFile()) { + file.withReader('UTF-8') { names.load(it) } + } + names.stringPropertyNames().toList().sort() + } + + private Properties descriptor(String className) { + Properties properties = new Properties() + output.resolve(TagLibraryIndex.INDEX_LOCATION + className + '.properties').toFile() + .withReader('UTF-8') { properties.load(it) } + properties + } +} diff --git a/grails-gsp/grails-web-taglib/src/test/groovy/org/grails/taglib/index/TagLibraryIndexGeneratorSpec.groovy b/grails-gsp/grails-web-taglib/src/test/groovy/org/grails/taglib/index/TagLibraryIndexGeneratorSpec.groovy new file mode 100644 index 00000000000..eda7e2286df --- /dev/null +++ b/grails-gsp/grails-web-taglib/src/test/groovy/org/grails/taglib/index/TagLibraryIndexGeneratorSpec.groovy @@ -0,0 +1,300 @@ +/* + * 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.index + +import java.nio.file.Files +import java.nio.file.Path + +import spock.lang.Specification +import spock.lang.TempDir + +/** + * Generating the index for a whole source set, rather than accumulating it as each class compiles, + * is what allows a renamed or deleted tag library to disappear from it. + */ +class TagLibraryIndexGeneratorSpec extends Specification { + + @TempDir + Path tempDir + + Path sources + Path output + + def setup() { + sources = Files.createDirectories(tempDir.resolve('src')) + output = Files.createDirectories(tempDir.resolve('out')) + } + + void 'a closure based tag is recorded as such'() { + given: + write('Legacy.groovy', ''' + import grails.gsp.TagLib + @TagLib + class LegacyTagLib { + static namespace = 'legacy' + def asMethod(Map attrs) { } + Closure asClosure = { Map attrs -> } + } + ''') + + when: + TagLibraryIndexGenerator.generate(sources.toFile(), output.toFile(), true, 'UTF-8') + + then: 'the closure form is marked so that callers keep dispatching it dynamically' + descriptor('LegacyTagLib').tags == 'asClosure,asMethod' + } + + void 'a tag library is described without being loaded or executed'() { + given: 'a tag library whose static initialiser would fail if it ran' + write('Explosive.groovy', ''' + import grails.gsp.TagLib + @TagLib + class ExplosiveTagLib { + static { throw new RuntimeException('must not run') } + static namespace = 'boom' + def alpha(Map attrs) { } + def beta(Map attrs, Closure body) { } + } + ''') + + when: + TagLibraryIndexGenerator.generate(sources.toFile(), output.toFile(), true, 'UTF-8') + + then: 'its tags are described from the source alone' + descriptor('ExplosiveTagLib').namespace == 'boom' + descriptor('ExplosiveTagLib').tags == 'alpha,beta' + } + + void 'a renamed tag library leaves nothing behind'() { + given: + write('First.groovy', taglib('OldNameTagLib', 'old', 'one')) + TagLibraryIndexGenerator.generate(sources.toFile(), output.toFile(), true, 'UTF-8') + assert descriptorFile('OldNameTagLib').exists() + + when: 'the tag library is renamed and the index regenerated' + Files.delete(sources.resolve('First.groovy')) + write('First.groovy', taglib('NewNameTagLib', 'old', 'one')) + TagLibraryIndexGenerator.generate(sources.toFile(), output.toFile(), true, 'UTF-8') + + then: 'the old descriptor is gone rather than describing a class that no longer exists' + !descriptorFile('OldNameTagLib').exists() + descriptorFile('NewNameTagLib').exists() + + and: 'the manifest names only what exists' + manifest() == ['NewNameTagLib'] + } + + void 'a deleted tag library leaves nothing behind'() { + given: + write('Gone.groovy', taglib('GoingTagLib', 'g', 'vanishes')) + TagLibraryIndexGenerator.generate(sources.toFile(), output.toFile(), true, 'UTF-8') + assert descriptorFile('GoingTagLib').exists() + + when: + Files.delete(sources.resolve('Gone.groovy')) + TagLibraryIndexGenerator.generate(sources.toFile(), output.toFile(), true, 'UTF-8') + + then: + !descriptorFile('GoingTagLib').exists() + manifest().isEmpty() + } + + void 'regenerating unchanged sources produces an identical index'() { + given: + write('A.groovy', taglib('AlphaTagLib', 'a', 'one')) + write('B.groovy', taglib('BetaTagLib', 'b', 'two')) + + when: + TagLibraryIndexGenerator.generate(sources.toFile(), output.toFile(), true, 'UTF-8') + String first = descriptorFile('AlphaTagLib').text + manifestFile().text + TagLibraryIndexGenerator.generate(sources.toFile(), output.toFile(), true, 'UTF-8') + String second = descriptorFile('AlphaTagLib').text + manifestFile().text + + then: 'byte for byte, so the build stays reproducible and up to date checks hold' + first == second + } + + void 'a tag library that cannot be resolved yet does not lose the others'() { + given: 'one tag library referring to something not on the classpath, as a service in the same project is' + write('Unresolvable.groovy', ''' + import grails.gsp.TagLib + import com.nowhere.NotOnTheClasspath + @TagLib + class UnresolvableTagLib { + static namespace = 'nope' + NotOnTheClasspath collaborator + def gone(Map attrs) { } + } + ''') + write('Fine.groovy', taglib('FineTagLib', 'fine', 'present')) + + when: + TagLibraryIndexGenerator.generate(sources.toFile(), output.toFile(), true, 'UTF-8') + + then: 'the one that reads is described' + descriptorFile('FineTagLib').exists() + descriptor('FineTagLib').tags == 'present' + + and: 'the one that does not is left out here, and describes itself when it is compiled' + !descriptorFile('UnresolvableTagLib').exists() + manifest() == ['FineTagLib'] + } + + void 'a class that is not a tag library is ignored'() { + given: + write('Service.groovy', 'class SomeService { def doThing(Map attrs) { } }') + + when: + TagLibraryIndexGenerator.generate(sources.toFile(), output.toFile(), true, 'UTF-8') + + then: + manifest().isEmpty() + } + + void 'several source directories are described in one pass'() { + given: 'tag libraries in two directories, as a project keeping some outside grails-app has' + Path other = Files.createDirectories(tempDir.resolve('other')) + write('First.groovy', taglib('FirstTagLib', 'first', 'one')) + other.resolve('Second.groovy').toFile().text = taglib('SecondTagLib', 'second', 'two') + + when: + TagLibraryIndexGenerator.generate([sources.toFile(), other.toFile()], output.toFile(), true, 'UTF-8') + + then: 'both are described' + manifest() == ['FirstTagLib', 'SecondTagLib'] + descriptor('FirstTagLib').namespace == 'first' + descriptor('SecondTagLib').namespace == 'second' + } + + void 'a tag library removed from one of several directories leaves nothing behind'() { + given: 'describing each directory in turn would either erase the last or keep the deleted one' + Path other = Files.createDirectories(tempDir.resolve('other')) + write('First.groovy', taglib('FirstTagLib', 'first', 'one')) + other.resolve('Second.groovy').toFile().text = taglib('SecondTagLib', 'second', 'two') + TagLibraryIndexGenerator.generate([sources.toFile(), other.toFile()], output.toFile(), true, 'UTF-8') + + when: 'one of them is deleted and the index regenerated' + other.resolve('Second.groovy').toFile().delete() + TagLibraryIndexGenerator.generate([sources.toFile(), other.toFile()], output.toFile(), true, 'UTF-8') + + then: 'only the one that still exists is described' + manifest() == ['FirstTagLib'] + } + + private void write(String name, String source) { + sources.resolve(name).toFile().text = source + } + + private static String taglib(String className, String namespace, String tag) { + """ + import grails.gsp.TagLib + @TagLib + class ${className} { + static namespace = '${namespace}' + def ${tag}(Map attrs) { } + } + """ + } + + private File descriptorFile(String simpleName) { + new File(output.toFile(), TagLibraryIndex.INDEX_LOCATION + simpleName + '.properties') + } + + private File manifestFile() { + new File(output.toFile(), TagLibraryIndex.INDEX_LOCATION + 'index.properties') + } + + private Properties descriptor(String simpleName) { + def properties = new Properties() + descriptorFile(simpleName).withReader('UTF-8') { properties.load(it) } + properties + } + + private List manifest() { + File file = manifestFile() + if (!file.exists()) { + return [] + } + def properties = new Properties() + file.withReader('UTF-8') { properties.load(it) } + properties.stringPropertyNames().sort() + } + void 'a class pulled in only to resolve a type is not described'() { + given: 'a helper named like a tag library, referenced as a superclass but never asked for' + Path taglibs = Files.createDirectories(tempDir.resolve('grails-app/taglib/demo')) + Path helpers = Files.createDirectories(tempDir.resolve('src/main/groovy/demo')) + helpers.resolve('SharedTagLib.groovy').toFile().text = """ + package demo + class SharedTagLib { + def helper(Map attrs) { 'not a tag' } + } + """ + taglibs.resolve('RealTagLib.groovy').toFile().text = """ + package demo + import grails.gsp.TagLib + @TagLib + class RealTagLib extends SharedTagLib { + static namespace = 'real' + def actual(Map attrs) { 'tag' } + } + """ + File out = Files.createDirectories(tempDir.resolve('out')).toFile() + + when: 'the helper root is a resolution root, not a source directory' + TagLibraryIndexGenerator.generate([tempDir.resolve('grails-app/taglib').toFile()], + [tempDir.resolve('src/main/groovy').toFile()], out, true, 'UTF-8') + + then: 'the tag library it was pointed at is described' + new File(out, 'META-INF/grails/taglibs/demo.RealTagLib.properties').isFile() + + and: 'and the helper is not, so its methods never become tags of the default namespace' + !new File(out, 'META-INF/grails/taglibs/demo.SharedTagLib.properties').isFile() + } + + void 'an abstract base class is not described'() { + given: 'a base kept beside the tag libraries that share it, which is where it belongs' + Path taglibs = Files.createDirectories(tempDir.resolve('grails-app/taglib/demo')) + taglibs.resolve('BaseTagLib.groovy').toFile().text = """ + package demo + abstract class BaseTagLib { + def common(Map attrs) { 'shared' } + } + """ + taglibs.resolve('MyTagLib.groovy').toFile().text = """ + package demo + import grails.gsp.TagLib + @TagLib + class MyTagLib extends BaseTagLib { + static namespace = 'my' + def own(Map attrs) { 'mine' } + } + """ + File out = Files.createDirectories(tempDir.resolve('out-abstract')).toFile() + + when: + TagLibraryIndexGenerator.generate(tempDir.resolve('grails-app/taglib').toFile(), out, true, 'UTF-8') + + then: 'artefact handling never registers an abstract class, so its methods are tags of nothing' + !new File(out, 'META-INF/grails/taglibs/demo.BaseTagLib.properties').isFile() + + and: 'the tag library that extends it is described as usual' + new File(out, 'META-INF/grails/taglibs/demo.MyTagLib.properties').text.contains('own') + } + +} diff --git a/grails-gsp/grails-web-taglib/src/test/groovy/org/grails/web/taglib/NoMetaClassMutationSpec.groovy b/grails-gsp/grails-web-taglib/src/test/groovy/org/grails/web/taglib/NoMetaClassMutationSpec.groovy new file mode 100644 index 00000000000..6cf27331b1b --- /dev/null +++ b/grails-gsp/grails-web-taglib/src/test/groovy/org/grails/web/taglib/NoMetaClassMutationSpec.groovy @@ -0,0 +1,104 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.web.taglib + +import groovy.lang.ExpandoMetaClass +import grails.core.DefaultGrailsApplication +import grails.core.gsp.GrailsTagLibClass +import grails.gsp.TagLib +import org.grails.core.gsp.DefaultGrailsTagLibClass +import org.grails.taglib.NamespacedTagDispatcher +import org.grails.taglib.TagLibraryLookup +import spock.lang.Specification + +/** + * Resolving a tag must not write to a metaclass. + * + *

Each dispatcher used to be built with its own ExpandoMetaClass carrying a method per tag, and + * every caller had the tags it used installed onto its own metaclass on first use. That made tag + * dispatch a read of an initialised ExpandoMetaClass, which is guarded by a read-write lock and was + * the largest single contended cost in profiles of concurrent rendering. + */ +class NoMetaClassMutationSpec extends Specification { + + void 'creating a namespace dispatcher does not build a metaclass for it'() { + given: + TagLibraryLookup lookup = newLookup() + + when: + NamespacedTagDispatcher dispatcher = + new NamespacedTagDispatcher('quiet', null, lookup.grailsApplication, lookup) + + then: 'no per instance ExpandoMetaClass is created and populated' + !(dispatcher.metaClass instanceof ExpandoMetaClass) || + !dispatcher.metaClass.hasMetaMethod('ping', [Map] as Class[]) + } + + void 'the dispatchers a lookup creates carry no tag methods'() { + given: + TagLibraryLookup lookup = newLookup() + lookup.registerTagLib(new DefaultGrailsTagLibClass(QuietTagLib)) + + when: + NamespacedTagDispatcher dispatcher = lookup.lookupNamespaceDispatcher('quiet') + + then: 'the tag is reachable' + dispatcher != null + lookup.lookupTagLibrary('quiet', 'ping') != null + + and: 'without a method having been installed for it' + !(dispatcher.metaClass instanceof ExpandoMetaClass) || + !dispatcher.metaClass.hasMetaMethod('ping', [Map] as Class[]) + } + + void 'the template namespace resolves without installing the template name'() { + given: + TagLibraryLookup lookup = newLookup() + def dispatcher = new org.grails.taglib.TemplateNamespacedTagDispatcher( + QuietTagLib, lookup.grailsApplication, lookup) + + expect: 'using a template name does not add a method for it' + !(dispatcher.metaClass instanceof ExpandoMetaClass) || + !dispatcher.metaClass.hasMetaMethod('someTemplate', [Map] as Class[]) + } + + void 'a page resolves an unqualified tag through a declared method'() { + expect: 'declared rather than installed, so compiling a page writes to no metaclass' + org.grails.gsp.GroovyPage.getDeclaredMethod('methodMissing', String, Object) != null + } + + private static TagLibraryLookup newLookup() { + def lookup = new TagLibraryLookup() { + @Override + protected void putTagLib(Map tags, String name, GrailsTagLibClass taglib) { + tags.put(name, taglib.newInstance()) + } + } + def application = new DefaultGrailsApplication([QuietTagLib] as Class[], TagLibraryLookup.classLoader) + application.initialise() + lookup.grailsApplication = application + lookup + } +} + +@TagLib +class QuietTagLib { + static namespace = 'quiet' + def ping(Map attrs) { 'pong' } +} diff --git a/grails-gsp/grails-web-taglib/src/test/groovy/org/grails/web/taglib/TagLibraryLookupIndexSpec.groovy b/grails-gsp/grails-web-taglib/src/test/groovy/org/grails/web/taglib/TagLibraryLookupIndexSpec.groovy new file mode 100644 index 00000000000..eeb7a328fbd --- /dev/null +++ b/grails-gsp/grails-web-taglib/src/test/groovy/org/grails/web/taglib/TagLibraryLookupIndexSpec.groovy @@ -0,0 +1,118 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.web.taglib + +import grails.core.DefaultGrailsApplication +import grails.core.gsp.GrailsTagLibClass +import grails.gsp.TagLib +import org.grails.core.gsp.DefaultGrailsTagLibClass +import org.grails.taglib.TagLibraryLookup +import spock.lang.Specification + +/** + * Registering a tag library uses the tags recorded when it was compiled, and falls back to + * discovering them from the class when there is no such record. + * + *

The fallback is what keeps a plugin built before the index existed, and a tag library registered + * while an application is being developed, working unchanged. + */ +class TagLibraryLookupIndexSpec extends Specification { + + void 'a tag library with no descriptor is still registered from the class'() { + given: 'a tag library compiled in this test source set, which carries no descriptor' + TagLibraryLookup lookup = newLookup(FallbackTagLib) + + when: + lookup.registerTagLib(new DefaultGrailsTagLibClass(FallbackTagLib)) + + then: 'its tags are discovered the previous way, so nothing regresses without an index' + lookup.lookupTagLibrary('fallback', 'discovered') != null + } + + void 'registration reports the same tags whichever route was taken'() { + given: + TagLibraryLookup lookup = newLookup(FallbackTagLib) + lookup.registerTagLib(new DefaultGrailsTagLibClass(FallbackTagLib)) + + expect: 'the set matches what the class itself declares' + lookup.getAvailableTags('fallback') == + new DefaultGrailsTagLibClass(FallbackTagLib).tagNames + + and: 'a method that is not a tag is absent either way' + !('helper' in lookup.getAvailableTags('fallback')) + } + + void 'a tag library registered after startup is picked up'() { + given: 'a lookup that has already registered one tag library' + TagLibraryLookup lookup = newLookup(FallbackTagLib) + lookup.registerTagLib(new DefaultGrailsTagLibClass(FallbackTagLib)) + + when: 'another is registered later, as reloading during development does' + lookup.registerTagLib(new DefaultGrailsTagLibClass(LateTagLib)) + + then: + lookup.lookupTagLibrary('late', 'arrived') != null + } + + void 'a string body is accepted by the namespaced dispatcher'() { + given: 'a dispatcher for a namespace, as a statically compiled page uses' + TagLibraryLookup lookup = newLookup(BodyTagLib) + lookup.registerTagLib(new DefaultGrailsTagLibClass(BodyTagLib)) + def dispatcher = new org.grails.taglib.TagLibNamespaceMethodDispatcher( + 'body', lookup, org.grails.taglib.encoder.OutputContextLookupHelper.lookupOutputContext()) + + when: 'the tag is called with a string body rather than a closure' + dispatcher.invokeMethod('wrap', [[:], 'text body'] as Object[]) + + then: 'it is adapted rather than failing to cast' + noExceptionThrown() + } + + private static TagLibraryLookup newLookup(Class... tagLibClasses) { + def lookup = new TagLibraryLookup() { + @Override + protected void putTagLib(Map tags, String name, GrailsTagLibClass taglib) { + tags.put(name, taglib.newInstance()) + } + } + def application = new DefaultGrailsApplication(tagLibClasses, TagLibraryLookup.classLoader) + application.initialise() + lookup.grailsApplication = application + lookup + } +} + +@TagLib +class FallbackTagLib { + static namespace = 'fallback' + def discovered(Map attrs) { 'discovered' } + String helper(String a, int b) { a } +} + +@TagLib +class BodyTagLib { + static namespace = 'body' + def wrap(Map attrs, Closure body) { body() } +} + +@TagLib +class LateTagLib { + static namespace = 'late' + def arrived(Map attrs) { 'arrived' } +} diff --git a/grails-gsp/grails-web-taglib/src/test/groovy/org/grails/web/taglib/TagPrecedenceSpec.groovy b/grails-gsp/grails-web-taglib/src/test/groovy/org/grails/web/taglib/TagPrecedenceSpec.groovy new file mode 100644 index 00000000000..c527e500ecf --- /dev/null +++ b/grails-gsp/grails-web-taglib/src/test/groovy/org/grails/web/taglib/TagPrecedenceSpec.groovy @@ -0,0 +1,112 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.web.taglib + +import grails.core.DefaultGrailsApplication +import grails.gsp.TagLib +import org.grails.core.gsp.DefaultGrailsTagLibClass +import org.grails.taglib.TagLibraryLookup +import spock.lang.Specification + +/** + * Characterises what happens when more than one tag library declares the same namespace and tag, + * as two plugins and an application overriding a plugin's tag both do. + * + *

This behaviour is the constraint any compile-time tag index has to respect. If a compiler + * resolved a duplicated tag to one tag library while the runtime dispatched to another, code would + * compile against one implementation and run against a different one. Nothing may change here + * without a deliberate decision, so it is pinned down before anything depends on it. + */ +class TagPrecedenceSpec extends Specification { + + void 'the tag library registered last wins a duplicated namespace and tag'() { + given: + TagLibraryLookup lookup = newLookup() + + when: 'two tag libraries declaring the same namespace and tag are registered in order' + lookup.registerTagLib(new DefaultGrailsTagLibClass(FirstDuplicateTagLib)) + lookup.registerTagLib(new DefaultGrailsTagLibClass(SecondDuplicateTagLib)) + + then: 'the later registration provides the tag' + lookup.lookupTagLibrary('dup', 'shared').getClass() == SecondDuplicateTagLib + + and: 'a tag only the earlier one declares is still reachable' + lookup.lookupTagLibrary('dup', 'onlyFirst').getClass() == FirstDuplicateTagLib + } + + void 'registration order alone decides the winner, not declaration order within a namespace'() { + given: + TagLibraryLookup lookup = newLookup() + + when: 'the same two tag libraries are registered the other way round' + lookup.registerTagLib(new DefaultGrailsTagLibClass(SecondDuplicateTagLib)) + lookup.registerTagLib(new DefaultGrailsTagLibClass(FirstDuplicateTagLib)) + + then: 'the winner flips, so precedence is positional and carries no inherent ranking' + lookup.lookupTagLibrary('dup', 'shared').getClass() == FirstDuplicateTagLib + } + + void 'returnObjectForTags follows the winning tag library rather than accumulating'() { + given: + TagLibraryLookup lookup = newLookup() + + when: 'the first declares the shared tag as returning an object and the second does not' + lookup.registerTagLib(new DefaultGrailsTagLibClass(FirstDuplicateTagLib)) + lookup.registerTagLib(new DefaultGrailsTagLibClass(SecondDuplicateTagLib)) + + then: 'the later registration resets it, so the two settings do not merge' + !lookup.doesTagReturnObject('dup', 'shared') + + when: 'registered the other way round' + TagLibraryLookup reversed = newLookup() + reversed.registerTagLib(new DefaultGrailsTagLibClass(SecondDuplicateTagLib)) + reversed.registerTagLib(new DefaultGrailsTagLibClass(FirstDuplicateTagLib)) + + then: + reversed.doesTagReturnObject('dup', 'shared') + } + + private static TagLibraryLookup newLookup() { + def lookup = new TagLibraryLookup() { + @Override + protected void putTagLib(Map tags, String name, grails.core.gsp.GrailsTagLibClass taglib) { + tags.put(name, taglib.newInstance()) + } + } + def application = new DefaultGrailsApplication( + [FirstDuplicateTagLib, SecondDuplicateTagLib] as Class[], TagLibraryLookup.classLoader) + application.initialise() + lookup.grailsApplication = application + lookup + } +} + +@TagLib +class FirstDuplicateTagLib { + static namespace = 'dup' + static returnObjectForTags = ['shared'] + def shared(Map attrs) { 'first' } + def onlyFirst(Map attrs) { 'onlyFirst' } +} + +@TagLib +class SecondDuplicateTagLib { + static namespace = 'dup' + def shared(Map attrs) { 'second' } +} diff --git a/grails-gsp/plugin/build.gradle b/grails-gsp/plugin/build.gradle index d18d85fd6fb..d9dff71f50a 100644 --- a/grails-gsp/plugin/build.gradle +++ b/grails-gsp/plugin/build.gradle @@ -146,6 +146,7 @@ dependencies { exclude group: 'org.apache.grails.web', module: 'grails-web-url-mappings' exclude group: 'org.apache.grails.views', module: 'grails-web-gsp' } + astImplementation project(':grails-web-taglib') astImplementation project(':grails-controllers'), { // API dependencies in grails-plugin-controllers //exclude group: 'org.apache.grails', module: 'grails-core' // TraitInjector diff --git a/grails-gsp/plugin/src/ast/groovy/grails/compiler/traits/CompiledTagCallTransformation.groovy b/grails-gsp/plugin/src/ast/groovy/grails/compiler/traits/CompiledTagCallTransformation.groovy new file mode 100644 index 00000000000..6f321a6e346 --- /dev/null +++ b/grails-gsp/plugin/src/ast/groovy/grails/compiler/traits/CompiledTagCallTransformation.groovy @@ -0,0 +1,106 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package grails.compiler.traits + +import groovy.transform.CompileStatic +import org.codehaus.groovy.ast.ASTNode +import org.codehaus.groovy.ast.ClassHelper +import org.codehaus.groovy.ast.ClassNode +import org.codehaus.groovy.ast.ModuleNode +import org.codehaus.groovy.control.CompilePhase +import org.codehaus.groovy.control.SourceUnit +import org.codehaus.groovy.transform.ASTTransformation +import org.codehaus.groovy.transform.GroovyASTTransformation +import org.codehaus.groovy.transform.TransformWithPriority + +import grails.artefact.gsp.TagLibraryInvoker +import grails.gsp.taglib.compiler.CompiledTagCallRewriter +import org.apache.grails.common.compiler.GroovyTransformOrder +import org.grails.taglib.index.TagLibraryIndex + +/** + * Compiles a call to a known tag into a direct invocation, wherever tags can be called from. + * + *

A tag library rewrites its own calls as it is compiled, but a controller can call tags too, and + * gains that ability from the {@link TagLibraryInvoker} trait rather than from being a tag library. + * Any class carrying that trait is therefore a candidate, which covers controllers without naming + * them and without a second copy of the rewriting rules. A compiled GSP calls tags as well, and + * reaches them through {@code GroovyPage} rather than through the trait, so it is matched separately. + * + *

Runs after trait injection, since whether a class can call tags is only settled once its traits + * have been applied. That ordering is declared rather than left to the default a transform without a + * priority gets, so a transform added later cannot quietly displace it. + * + * @since 8.0.0 + */ +@CompileStatic +@GroovyASTTransformation(phase = CompilePhase.CANONICALIZATION) +class CompiledTagCallTransformation implements ASTTransformation, TransformWithPriority { + + private static final ClassNode TAG_LIBRARY_INVOKER = ClassHelper.make(TagLibraryInvoker) + + /** + * Matched by name rather than by type: the page runtime is not on the classpath this + * transformation is compiled against, and need not be. + */ + private static final String GROOVY_PAGE = 'org.grails.gsp.GroovyPage' + + @Override + void visit(ASTNode[] nodes, SourceUnit source) { + ModuleNode module = source.getAST() + if (module == null) { + return + } + TagLibraryIndex index = null + for (ClassNode classNode : module.getClasses()) { + if (!callsTags(classNode)) { + continue + } + if (index == null) { + index = TagLibraryIndex.forClassLoader(source.getClassLoader()) + if (index.isEmpty()) { + return + } + } + new CompiledTagCallRewriter(source, index, classNode).rewrite() + } + } + + /** + * @return true when the class can call tags, which is what carrying the tag library invoker trait + * means, whether it is a controller, a tag library or anything else given that ability, + * or what being a compiled page means + */ + private static boolean callsTags(ClassNode classNode) { + if (classNode.implementsInterface(TAG_LIBRARY_INVOKER) || classNode.declaresInterface(TAG_LIBRARY_INVOKER)) { + return true + } + for (ClassNode current = classNode.superClass; current != null; current = current.superClass) { + if (GROOVY_PAGE == current.name) { + return true + } + } + false + } + + @Override + int priority() { + GroovyTransformOrder.COMPILED_TAG_CALL_ORDER + } +} diff --git a/grails-gsp/plugin/src/ast/resources/META-INF/services/org.codehaus.groovy.transform.ASTTransformation b/grails-gsp/plugin/src/ast/resources/META-INF/services/org.codehaus.groovy.transform.ASTTransformation new file mode 100644 index 00000000000..58d28c215b4 --- /dev/null +++ b/grails-gsp/plugin/src/ast/resources/META-INF/services/org.codehaus.groovy.transform.ASTTransformation @@ -0,0 +1 @@ +grails.compiler.traits.CompiledTagCallTransformation diff --git a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/GroovyPagesGrailsPlugin.groovy b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/GroovyPagesGrailsPlugin.groovy index 3a274eb2835..ca539e4fb09 100644 --- a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/GroovyPagesGrailsPlugin.groovy +++ b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/GroovyPagesGrailsPlugin.groovy @@ -55,7 +55,6 @@ import org.grails.plugins.web.taglib.UrlMappingTagLib import org.grails.plugins.web.taglib.ValidationTagLib import org.grails.spring.RuntimeSpringConfiguration import org.grails.taglib.TagLibraryLookup -import org.grails.taglib.TagLibraryMetaUtils import org.grails.web.errors.ErrorsViewStackTracePrinter import org.grails.web.gsp.GroovyPagesTemplateRenderer import org.grails.web.gsp.io.CachingGrailsConventionGroovyPageLocator @@ -326,8 +325,9 @@ class GroovyPagesGrailsPlugin extends Plugin { // The tag library lookup class caches 'tag -> taglib class' // so we need to update it now. def lookup = applicationContext.getBean('gspTagLibraryLookup', TagLibraryLookup) + // Registering with the lookup is enough: tags are resolved through it rather than + // installed onto each tag library's metaclass. lookup.registerTagLib(taglibClass) - TagLibraryMetaUtils.enhanceTagLibMetaClass(taglibClass, lookup) } } // clear uri cache after changes diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/CompiledPageTagCallSpec.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/CompiledPageTagCallSpec.groovy new file mode 100644 index 00000000000..57f4bf0c2c4 --- /dev/null +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/CompiledPageTagCallSpec.groovy @@ -0,0 +1,138 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.web.taglib + +import java.nio.file.Files +import java.nio.file.Path + +import org.grails.gsp.compiler.GroovyPageCompiler +import spock.lang.Specification +import spock.lang.TempDir + +/** + * A page reaches a tag written in an expression through the namespace dispatcher, which resolves the + * tag by name every time the page renders. Where the namespace and the tag are both written in the + * page and the index knows them, there is nothing left to resolve, so the call is compiled the same + * way it is in a tag library. + * + *

Compiled to disk rather than in memory, because what is being asserted is what was compiled and + * the dynamic route renders identically. + */ +class CompiledPageTagCallSpec extends Specification { + + private static final String INVOCATION = 'org/grails/taglib/CompiledTagInvocation' + + @TempDir + Path tempDir + + private static final String STATIC = '<%@ page compileStatic="true" %>' + + void 'a tag expression naming a known tag is compiled into an invocation'() { + when: + byte[] page = compilePage('known.gsp', STATIC + '''${g.createLink(controller: 'book')}''') + + then: + references(page) + } + + void 'a page that has not declared compileStatic keeps resolving its tags as before'() { + when: 'such a page resolves a name against the model it was rendered with, which is not known here' + byte[] page = compilePage('dynamic.gsp', '''${g.createLink(controller: 'book')}''') + + then: 'so a model attribute named after a namespace still wins, as it always did' + !references(page) + } + + void 'a tag expression inside page markup is compiled into an invocation'() { + when: 'the expression sits in a block the page compiles into a closure' + byte[] page = compilePage('nested.gsp', + STATIC + '''${g.createLink(controller: 'book')}''') + + then: + references(page) + } + + void 'an expression in a namespace no compiled tag library declares is left dynamic'() { + when: 'such a namespace has to be declared to a statically compiled page, as it always did' + byte[] page = compilePage('unknown-ns.gsp', + '''<%@ page compileStatic="true" taglibs="somepluginns" %>${somepluginns.anything(a: 1)}''') + + then: 'it keeps resolving through the dispatcher, which is what a runtime tag library needs' + !references(page) + } + + void 'a page variable named after a namespace is that variable, not the namespace'() { + when: 'the page put the name into its own binding, where it is resolved before any tag library' + byte[] page = compilePage('shadowed.gsp', STATIC + + '''${g.createLink(controller: 'book')}''') + + then: + !references(page) + } + + void 'an unqualified call in a page is left to resolve against the binding'() { + when: 'the model a page renders with is not visible when it is compiled' + byte[] page = compilePage('unqualified.gsp', STATIC + '''${createLink(controller: 'book')}''') + + then: + !references(page) + } + + void 'a tag written as markup stays an ordinary invokeTag call'() { + when: 'markup already compiles into a direct call naming the tag, so there is nothing to rewrite' + byte[] page = compilePage('markup.gsp', STATIC + '''''') + + then: + !references(page) + } + + private byte[] compilePage(String name, String contents) { + Path viewsDir = Files.createDirectories(tempDir.resolve('views-' + name)) + Path targetDir = Files.createDirectories(tempDir.resolve('classes-' + name)) + viewsDir.resolve(name).toFile().text = contents + + GroovyPageCompiler compiler = new GroovyPageCompiler() + compiler.viewsDir = viewsDir.toFile() + compiler.targetDir = targetDir.toFile() + compiler.srcFiles = [viewsDir.resolve(name).toFile()] + compiler.compile() + + List compiled = [] + collectClasses(targetDir.toFile(), compiled) + assert compiled : "the page was not compiled to a class file" + // The page and any closure it compiles into are read together: a tag call written inside + // markup lands in a closure rather than in the page class itself. + compiled.collect { File file -> file.bytes }.flatten() as byte[] + } + + private static void collectClasses(File directory, List into) { + directory.listFiles()?.each { File file -> + if (file.isDirectory()) { + collectClasses(file, into) + } + else if (file.name.endsWith('.class')) { + into << file + } + } + } + + private static boolean references(byte[] classBytes) { + new String(classBytes, 'ISO-8859-1').contains(INVOCATION) + } +} diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/CompiledTagCallBytecodeSpec.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/CompiledTagCallBytecodeSpec.groovy new file mode 100644 index 00000000000..fe48f505da9 --- /dev/null +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/CompiledTagCallBytecodeSpec.groovy @@ -0,0 +1,284 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.web.taglib + +import java.nio.file.Files +import java.nio.file.Path + +import org.codehaus.groovy.control.CompilationUnit +import org.codehaus.groovy.control.CompilerConfiguration +import org.grails.taglib.index.TagLibraryIndex +import spock.lang.Specification +import spock.lang.TempDir + +/** + * That a rewritten call produces the right output says nothing about whether it was rewritten, since + * the dynamic route produces the same output. This looks at what was actually compiled. + */ +class CompiledTagCallBytecodeSpec extends Specification { + + private static final String INVOCATION = 'org/grails/taglib/CompiledTagInvocation' + + @TempDir + Path tempDir + + void 'a call to a known tag is compiled as an invocation, not a dynamic call'() { + when: + Path compiled = compile(''' + import grails.gsp.TagLib + @TagLib + class BytecodeCheckTagLib { + static namespace = 'bytecheck' + def calls(Map attrs) { + out << g.createLink(controller: 'book') + } + } + ''', 'BytecodeCheckTagLib') + + then: 'the invocation entry point is referenced' + references(compiled, 'BytecodeCheckTagLib') + } + + void 'a call to a known tag written inside a closure is compiled as an invocation'() { + when: 'the call is in a block passed to another method, where most tag calls in real code are' + Path compiled = compile(''' + import grails.gsp.TagLib + @TagLib + class ClosureBodyCallerTagLib { + static namespace = 'closurebody' + def calls(Map attrs) { + [1, 2].each { n -> + out << g.createLink(controller: 'book') + } + } + } + ''', 'ClosureBodyCallerTagLib') + + then: 'the closure carries the invocation, not a dynamic call site' + references(compiled, 'ClosureBodyCallerTagLib$_calls_closure1') + } + + void 'a call to a known tag written inside a tag body is compiled as an invocation'() { + when: 'a tag body is a closure, so a tag called within one has to be reached through it' + Path compiled = compile(''' + import grails.gsp.TagLib + @TagLib + class NestedBodyTagLib { + static namespace = 'nestedbody' + def calls(Map attrs) { + out << g.formatDate(date: new Date()) { + g.createLink(controller: 'book') + } + } + } + ''', 'NestedBodyTagLib') + + then: 'both the outer call and the one inside the body are rewritten' + references(compiled, 'NestedBodyTagLib') + references(compiled, 'NestedBodyTagLib$_calls_closure1') + } + + void 'a call to a known tag written in a constructor is compiled as an invocation'() { + when: + Path compiled = compile(''' + import grails.gsp.TagLib + @TagLib + class ConstructorCallerTagLib { + static namespace = 'ctorcaller' + String cached + ConstructorCallerTagLib() { + cached = g.createLink(controller: 'book') + } + def calls(Map attrs) { out << cached } + } + ''', 'ConstructorCallerTagLib') + + then: + references(compiled, 'ConstructorCallerTagLib') + } + + void 'a call whose attributes are only known at runtime is compiled as an invocation too'() { + when: 'the shape is not evident in the source, so the arguments are forwarded as written' + Path compiled = compile(''' + import grails.gsp.TagLib + @TagLib + class ComputedAttrsTagLib { + static namespace = 'computedattrs' + def calls(Map attrs) { + Map linkAttrs = [controller: 'book'] + out << g.createLink(linkAttrs) + } + } + ''', 'ComputedAttrsTagLib') + + then: + references(compiled, 'ComputedAttrsTagLib') + } + + void 'an unqualified call to a known tag is left dynamic unless the build asks otherwise'() { + when: 'nothing in the tag library answers to the name, but the source named no namespace' + Path compiled = compile(''' + import grails.gsp.TagLib + @TagLib + class UnqualifiedCallerTagLib { + static namespace = 'unqualified' + def calls(Map attrs) { + out << createLink(controller: 'book') + } + } + ''', 'UnqualifiedCallerTagLib') + + then: 'compiling it would need this build to have enabled unqualifiedTagCalls' + !references(compiled, 'UnqualifiedCallerTagLib') + } + + void 'an unqualified call a local variable answers to is left alone'() { + when: 'a local holding a closure answers to the name, so the call is not a tag call' + Path compiled = compile(''' + import grails.gsp.TagLib + @TagLib + class LocalShadowTagLib { + static namespace = 'localshadow' + def calls(Map attrs) { + def createLink = { Map a -> 'local' } + out << createLink(controller: 'book') + } + } + ''', 'LocalShadowTagLib') + + then: + !references(compiled, 'LocalShadowTagLib') + } + + void 'a call into a namespace no compiled tag library declares is left dynamic'() { + when: + Path compiled = compile(''' + import grails.gsp.TagLib + @TagLib + class UntouchedTagLib { + static namespace = 'untouched' + def calls(Map attrs) { + out << nosuchnamespace.whatever(a: 1) + } + } + ''', 'UntouchedTagLib') + + then: 'nothing was rewritten, so it resolves as it did before' + !references(compiled, 'UntouchedTagLib') + } + + void 'a closure based tag is a valid target, since the tag is still selected at runtime'() { + when: 'g.link is declared as a Closure field; the invocation resolves it by name as before' + Path compiled = compile(''' + import grails.gsp.TagLib + @TagLib + class ClosureTagCallerTagLib { + static namespace = 'closurecaller' + def calls(Map attrs) { + out << g.link(controller: 'book') + } + } + ''', 'ClosureTagCallerTagLib') + + then: + references(compiled, 'ClosureTagCallerTagLib') + + and: 'it is known, so it is never reported as a misspelling' + TagLibraryIndex.load(getClass().classLoader).isKnown('g', 'link') + } + + void 'a known tag in a namespace the build declared dynamic is left alone'() { + given: 'declaring a namespace dynamic is how a build keeps its tags decided while it runs' + ClassLoader dynamicNamespace = loaderDeclaring('dynamicTagNamespaces=g\n') + + when: + Path compiled = compile(''' + import grails.gsp.TagLib + @TagLib + class DeclaredDynamicTagLib { + static namespace = 'declareddynamic' + def calls(Map attrs) { + out << g.createLink(controller: 'book') + } + } + ''', 'DeclaredDynamicTagLib', dynamicNamespace) + + then: 'the tag is known, but the declaration turns resolution off rather than only reporting' + TagLibraryIndex.load(getClass().classLoader).isKnown('g', 'createLink') + !references(compiled, 'DeclaredDynamicTagLib') + } + + void 'an unqualified call to a tag in a namespace declared dynamic is left alone'() { + given: + ClassLoader dynamicNamespace = loaderDeclaring('dynamicTagNamespaces=g\n') + + when: + Path compiled = compile(''' + import grails.gsp.TagLib + @TagLib + class DeclaredDynamicUnqualifiedTagLib { + static namespace = 'declareddynamicunqualified' + def calls(Map attrs) { + out << createLink(controller: 'book') + } + } + ''', 'DeclaredDynamicUnqualifiedTagLib', dynamicNamespace) + + then: + !references(compiled, 'DeclaredDynamicUnqualifiedTagLib') + } + + void 'the index this build compiles against is populated'() { + expect: 'otherwise the first case would pass for the wrong reason' + TagLibraryIndex.load(getClass().classLoader).lookup('g', 'createLink') != null + } + + /** + * What a build declares reaches the compiler as a classpath resource written by the + * {@code generateTagLibraryIndex} task, so a compilation meant to see it is given a loader that can. + */ + private ClassLoader loaderDeclaring(String settings) { + Path settingsDir = Files.createDirectories(tempDir.resolve('settings-' + settings.hashCode())) + Path indexDir = Files.createDirectories(settingsDir.resolve(TagLibraryIndex.INDEX_LOCATION)) + indexDir.resolve('compile-settings.properties').toFile().text = settings + new URLClassLoader([settingsDir.toUri().toURL()] as URL[], getClass().classLoader) + } + + private static boolean references(Path outputDir, String className) { + File classFile = outputDir.resolve(className + '.class').toFile() + assert classFile.exists() : "no class file compiled for ${className}" + new String(classFile.bytes, 'ISO-8859-1').contains(INVOCATION) + } + + private Path compile(String source, String className, ClassLoader parent = null) { + Path sourceFile = tempDir.resolve(className + '.groovy') + sourceFile.toFile().text = source + Path outputDir = Files.createDirectories(tempDir.resolve('classes-' + className)) + + CompilerConfiguration configuration = new CompilerConfiguration() + configuration.targetDirectory = outputDir.toFile() + configuration.parameters = true + CompilationUnit unit = new CompilationUnit(configuration, null, + new GroovyClassLoader(parent ?: getClass().classLoader, configuration)) + unit.addSource(sourceFile.toFile()) + unit.compile() + + outputDir + } +} diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/CompiledTagCallRewriterSpec.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/CompiledTagCallRewriterSpec.groovy new file mode 100644 index 00000000000..00cf66e6686 --- /dev/null +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/CompiledTagCallRewriterSpec.groovy @@ -0,0 +1,53 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.web.taglib + +import grails.testing.web.taglib.TagLibUnitTest +import org.grails.plugins.web.taglib.ApplicationTagLib +import spock.lang.Specification + +/** + * A call to a tag the build already knows about is compiled into a direct invocation instead of being + * dispatched dynamically, and has to behave identically. + * + *

The rewrite is verified through what the tag library produces rather than by reading bytecode: + * a rewritten call that produced different output would be the failure that matters. + */ +class CompiledTagCallRewriterSpec extends Specification implements TagLibUnitTest { + + void 'a namespaced call with attributes produces what the tag produces'() { + expect: + applyTemplate('') == applyTemplate('') + } + + void 'a namespaced call with a body passes the body through'() { + expect: + applyTemplate('').contains('inside') + } + + void 'a call to a tag the index does not hold still resolves'() { + expect: 'left dynamic, so a tag library registered at runtime keeps working' + applyTemplate('') == 'fallback' + } + + void 'a call whose arguments are not a recognisable tag call is left alone'() { + expect: 'the attributes are built at runtime, so the shape is not evident when compiling' + applyTemplate('') == applyTemplate('') + } +} diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/CompiledTagCallTransformationOrderSpec.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/CompiledTagCallTransformationOrderSpec.groovy new file mode 100644 index 00000000000..5c18bd299bf --- /dev/null +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/CompiledTagCallTransformationOrderSpec.groovy @@ -0,0 +1,58 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.web.taglib + +import grails.compiler.traits.CompiledTagCallTransformation +import org.apache.grails.common.compiler.GroovyTransformOrder +import org.codehaus.groovy.transform.TransformWithPriority +import spock.lang.Specification + +/** + * Whether a class can call tags is only settled once the traits that let it have been applied, so the + * rewriting has to run after the transforms that apply them. + * + *

That used to hold by accident: a transform declaring no priority defaults to zero, which happened + * to place it last. Declaring the order means a transform added later cannot displace it, and this + * pins the relationship rather than the number. + */ +class CompiledTagCallTransformationOrderSpec extends Specification { + + void 'the transformation declares its order rather than relying on a default'() { + expect: + new CompiledTagCallTransformation() instanceof TransformWithPriority + } + + void 'it runs after the transforms that inject artefact traits'() { + given: 'the registry decrements, so a later transform has the lower priority' + int rewriting = new CompiledTagCallTransformation().priority() + + expect: 'the trait a controller calls tags through has been applied by the time this runs' + rewriting < GroovyTransformOrder.ARTIFACT_TYPE_ORDER + rewriting < GroovyTransformOrder.GLOBAL_GRAILS_TRANSFORM_ORDER + } + + void 'it runs after every other transform the registry orders'() { + given: + int rewriting = new CompiledTagCallTransformation().priority() + + expect: 'nothing else can introduce a tag-calling class after the rewriting has run' + rewriting == GroovyTransformOrder.COMPILED_TAG_CALL_ORDER + rewriting < GroovyTransformOrder.COMMAND_FACTORIES_ORDER + } +} diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/CompiledTagInvocationSpec.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/CompiledTagInvocationSpec.groovy new file mode 100644 index 00000000000..8f5517ecbff --- /dev/null +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/CompiledTagInvocationSpec.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.web.taglib + +import grails.testing.web.taglib.TagLibUnitTest +import org.grails.plugins.web.taglib.ApplicationTagLib +import org.grails.taglib.CompiledTagInvocation +import org.grails.taglib.GrailsTagException +import org.grails.taglib.TagLibraryLookup +import spock.lang.Specification + +/** + * A tag whose namespace and name are already known is invoked as an ordinary method call rather than + * through Groovy's dispatch, and must behave exactly as the dynamic route does. + */ +class CompiledTagInvocationSpec extends Specification implements TagLibUnitTest { + + private TagLibraryLookup getLookup() { + applicationContext.getBean(TagLibraryLookup) + } + + void 'a tag that writes to the output returns what it wrote'() { + when: + Object output = CompiledTagInvocation.invoke( + lookup, 'g', 'link', [controller: 'book', action: 'show'], null) + + then: + output.toString() == applyTemplate('') + } + + void 'a tag called with a body receives it'() { + given: + Closure body = { 'inside' } + + when: + Object output = CompiledTagInvocation.invoke( + lookup, 'g', 'link', [controller: 'book'], body) + + then: + output.toString().contains('inside') + } + + void 'attributes may be omitted'() { + expect: 'a null attribute map is treated as empty rather than failing' + CompiledTagInvocation.invoke(lookup, 'g', 'link', null, { 'x' }) != null + } + + void 'invoking without a tag library lookup is reported clearly'() { + when: + CompiledTagInvocation.invoke(null, 'g', 'link', [:], null) + + then: + GrailsTagException e = thrown() + e.message.contains('link') + } + + void 'arguments forwarded as written are read the same way dynamic dispatch reads them'() { + given: 'the shapes TagLibraryMetaUtils.methodMissingForTagLib distinguishes' + Map attrs = [controller: 'book', action: 'show'] + + expect: 'a map alone is the attributes' + CompiledTagInvocation.invokeArguments(lookup, 'g', 'link', attrs).toString() == + CompiledTagInvocation.invoke(lookup, 'g', 'link', attrs, null).toString() + + and: 'a map and a body are both taken' + CompiledTagInvocation.invokeArguments(lookup, 'g', 'link', attrs, { 'inside' }).toString() == + CompiledTagInvocation.invoke(lookup, 'g', 'link', attrs, { 'inside' }).toString() + + and: 'a closure alone is the body' + CompiledTagInvocation.invokeArguments(lookup, 'g', 'link', { 'inside' }).toString() == + CompiledTagInvocation.invoke(lookup, 'g', 'link', [:], { 'inside' }).toString() + + and: 'no arguments means no attributes and no body' + CompiledTagInvocation.invokeArguments(lookup, 'g', 'link').toString() == + CompiledTagInvocation.invoke(lookup, 'g', 'link', [:], null).toString() + } + + void 'a single value that is neither a map nor a body is read under the tag name'() { + when: 'a number cannot be a body, so it becomes an attribute named after the tag' + String asAttribute = CompiledTagInvocation.invokeArguments(lookup, 'g', 'link', 5).toString() + + then: 'which is what dynamic dispatch does with such a call' + asAttribute == CompiledTagInvocation.invoke(lookup, 'g', 'link', [link: 5], null).toString() + + and: 'and it is not rendered as the body would be' + !asAttribute.contains('>5<') + } + + void 'a single value that is text is the body'() { + when: + String asBody = CompiledTagInvocation.invokeArguments(lookup, 'g', 'link', 'inside').toString() + + then: 'a CharSequence is a body, as it is on the dynamic route' + asBody == CompiledTagInvocation.invoke(lookup, 'g', 'link', [:], 'inside').toString() + asBody.contains('inside') + } +} diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/ControllerTagCallRewriteSpec.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/ControllerTagCallRewriteSpec.groovy new file mode 100644 index 00000000000..1510baf5b50 --- /dev/null +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/ControllerTagCallRewriteSpec.groovy @@ -0,0 +1,137 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.web.taglib + +import java.nio.file.Files +import java.nio.file.Path + +import org.codehaus.groovy.control.CompilationUnit +import org.codehaus.groovy.control.CompilerConfiguration +import spock.lang.Specification +import spock.lang.TempDir + +/** + * A controller can call tags too, through the tag library invoker trait rather than by being a tag + * library, so the same rewriting has to reach it. + * + *

Checked in the class file, because a rewritten call and a dynamic one produce the same output. + * + *

A controller declared by convention, under {@code grails-app/controllers}, is not covered here. + * Driving that path needs the artefact injector to recognise the source by its location, which depends + * on where the compilation happens rather than on what is being compiled, and a version of this spec + * that compiled a file into a temporary {@code grails-app/controllers} directory passed on one + * operating system and failed on two others. The convention path is exercised for real by every + * application under {@code grails-test-examples}, whose controllers live in that directory and whose + * tag calls are compiled; what is pinned here is the trait, which is what the rewriting actually keys + * on, and the annotated case below, which the trait reaches too late. + */ +class ControllerTagCallRewriteSpec extends Specification { + + @TempDir + Path tempDir + + void 'a class that can call tags has its tag calls compiled into invocations'() { + when: 'a class carrying the tag library invoker trait, as a controller does' + byte[] compiled = compile(''' + import grails.artefact.gsp.TagLibraryInvoker + class TagCallingController implements TagLibraryInvoker { + def index() { + g.createLink(controller: 'book') + } + } + ''', 'TagCallingController') + + then: + references(compiled, 'org/grails/taglib/CompiledTagInvocation') + } + + void 'a class that cannot call tags is left alone'() { + when: 'no tag library invoker trait, so g is not a namespace here' + byte[] compiled = compile(''' + class PlainService { + def index() { + g.createLink(controller: 'book') + } + } + ''', 'PlainService') + + then: + !references(compiled, 'org/grails/taglib/CompiledTagInvocation') + } + + void 'a controller declared by annotation outside that directory is not rewritten'() { + when: 'the trait arrives from a local transform, which runs after every global one' + byte[] compiled = compileAt('src/main/groovy/demo', ''' + package demo + + import grails.artefact.Artefact + + @Artefact('Controller') + class AnnotatedController { + def index() { + g.createLink(controller: 'book') + } + } + ''', 'AnnotatedController', 'demo') + + then: 'a known limitation rather than an intent: the call is dispatched as it was before, so ' + + 'it behaves correctly, it just does not get the faster path' + !references(compiled, 'org/grails/taglib/CompiledTagInvocation') + + and: 'and it really is a controller, so the difference is the source layout alone' + references(compiled, 'grails/artefact/gsp/TagLibraryInvoker') + } + + private static boolean references(byte[] classBytes, String internalName) { + new String(classBytes, 'ISO-8859-1').contains(internalName) + } + + private byte[] compileAt(String relativeDir, String source, String className, String packageName) { + Path sourceDir = Files.createDirectories(tempDir.resolve(relativeDir)) + Path sourceFile = sourceDir.resolve(className + '.groovy') + sourceFile.toFile().text = source + Path outputDir = Files.createDirectories(tempDir.resolve('out-' + className)) + + CompilerConfiguration configuration = new CompilerConfiguration() + configuration.targetDirectory = outputDir.toFile() + configuration.parameters = true + CompilationUnit unit = new CompilationUnit(configuration, null, + new GroovyClassLoader(getClass().classLoader, configuration)) + unit.addSource(sourceFile.toFile()) + unit.compile() + + Files.readAllBytes(outputDir.resolve(packageName.replace('.', '/')).resolve(className + '.class')) + } + + private byte[] compile(String source, String className) { + Path sourceFile = tempDir.resolve(className + '.groovy') + sourceFile.toFile().text = source + Path outputDir = Files.createDirectories(tempDir.resolve('classes-' + className)) + + CompilerConfiguration configuration = new CompilerConfiguration() + configuration.targetDirectory = outputDir.toFile() + configuration.parameters = true + CompilationUnit unit = new CompilationUnit(configuration, null, + new GroovyClassLoader(getClass().classLoader, configuration)) + unit.addSource(sourceFile.toFile()) + unit.compile() + + Files.readAllBytes(outputDir.resolve(className + '.class')) + } +} diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/GroovyMethodNameCollisionSpec.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/GroovyMethodNameCollisionSpec.groovy new file mode 100644 index 00000000000..2f8f83b4829 --- /dev/null +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/GroovyMethodNameCollisionSpec.groovy @@ -0,0 +1,212 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.web.taglib + +import java.nio.file.Files +import java.nio.file.Path + +import org.codehaus.groovy.control.CompilationUnit +import org.codehaus.groovy.control.CompilerConfiguration +import org.grails.taglib.index.TagLibraryIndexGenerator +import spock.lang.Specification +import spock.lang.TempDir +import spock.lang.Unroll + +/** + * A tag whose name is also a method Groovy gives every object must not capture an unqualified call to + * that method. + * + *

{@code with}, {@code each} and the rest of {@code DefaultGroovyMethods} are real methods on every + * receiver, so a bare {@code with { }} reached one directly and never went near {@code methodMissing}. + * Rewriting it into a tag invocation because a tag library happens to declare a tag of that name would + * silently send the call somewhere the author never wrote — and the collision is not hypothetical: + * grails-fields declares {@code f:with}. + * + *

Checked in the class file, because a call left dynamic and one rewritten wrongly both compile. + */ +class GroovyMethodNameCollisionSpec extends Specification { + + @TempDir + Path tempDir + + Path indexDir + + def setup() { + Path taglibSources = Files.createDirectories(tempDir.resolve('grails-app/taglib/demo')) + taglibSources.resolve('CollidingTagLib.groovy').toFile().text = ''' + package demo + + import grails.gsp.TagLib + + @TagLib + class CollidingTagLib { + static namespace = 'collide' + def with(Map attrs, Closure body) { 'tag' } + def each(Map attrs, Closure body) { 'tag' } + def greeting(Map attrs) { 'hello' } + } + ''' + indexDir = Files.createDirectories(tempDir.resolve('build/generated/grails-taglibs')) + TagLibraryIndexGenerator.generate( + tempDir.resolve('grails-app/taglib').toFile(), indexDir.toFile(), true, 'UTF-8') + } + + @Unroll + void 'the index describes the colliding tag #tagName'() { + expect: 'otherwise a case below would pass because the tag was unknown, not because it was reserved' + new File(indexDir.toFile(), 'META-INF/grails/taglibs/demo.CollidingTagLib.properties').text + .contains(tagName) + + where: + tagName << ['with', 'each'] + } + + @Unroll + void 'an unqualified call to #tagName is left for Groovy to answer'() { + when: 'a tag library in the same namespace as the tag library declaring that tag' + byte[] compiled = compileWithIndexOnClasspath(""" + package demo + + import grails.artefact.gsp.TagLibraryInvoker + + class ${className} implements TagLibraryInvoker { + static namespace = 'collide' + def run() { + ${expression} + } + } + """, className, 'demo') + + then: 'DefaultGroovyMethods still wins, as it did before any of this existed' + !references(compiled) + + where: 'each written bare, so the receiver is this and the call is the shape that gets rewritten' + tagName | className | expression + 'with' | 'WithCaller' | 'with { 1 }' + 'each' | 'EachCaller' | 'each { it }' + } + + void 'a namespaced call to the same tag is still rewritten'() { + when: 'the source says which tag library it means, so nothing is being guessed' + byte[] compiled = compileWithIndexOnClasspath(''' + package demo + + import grails.artefact.gsp.TagLibraryInvoker + + class QualifiedCaller implements TagLibraryInvoker { + static namespace = 'collide' + def run() { + collide.with(a: 1) { 'body' } + } + } + ''', 'QualifiedCaller', 'demo') + + then: 'reserving the name only ever affects a call that did not name its namespace' + references(compiled) + } + + void 'an unqualified call is not compiled unless the build asks for it'() { + when: 'nothing else answers to the name, but the build has not enabled unqualified calls' + byte[] compiled = compileWithIndexOnClasspath(''' + package demo + + import grails.artefact.gsp.TagLibraryInvoker + + class GreetingCaller implements TagLibraryInvoker { + static namespace = 'collide' + def run() { + greeting(name: 'world') + } + } + ''', 'GreetingCaller', 'demo') + + then: 'a bare name is a tag only when nothing nearer answers to it, which is not fully visible here' + !references(compiled) + } + + void 'an unqualified call is compiled when the build asks for it'() { + given: + enableUnqualifiedCalls() + + when: + byte[] compiled = compileWithIndexOnClasspath(""" + package demo + + import grails.artefact.gsp.TagLibraryInvoker + + class OptedInCaller implements TagLibraryInvoker { + static namespace = 'collide' + def run() { + greeting(name: 'world') + } + } + """, 'OptedInCaller', 'demo') + + then: + references(compiled) + } + + void 'a name Groovy answers to stays dynamic even when the build asks for unqualified calls'() { + given: 'the opt-in widens which calls are considered, not which names may be captured' + enableUnqualifiedCalls() + + when: + byte[] compiled = compileWithIndexOnClasspath(""" + package demo + + import grails.artefact.gsp.TagLibraryInvoker + + class OptedInCollider implements TagLibraryInvoker { + static namespace = 'collide' + def run() { + with { 1 } + } + } + """, 'OptedInCollider', 'demo') + + then: + !references(compiled) + } + + private void enableUnqualifiedCalls() { + File settings = new File(indexDir.toFile(), 'META-INF/grails/taglibs/compile-settings.properties') + settings.parentFile.mkdirs() + settings.text = 'dynamicTagNamespaces=\nstrictTags=false\nunqualifiedTagCalls=true\n' + } + + private static boolean references(byte[] classBytes) { + new String(classBytes, 'ISO-8859-1').contains('org/grails/taglib/CompiledTagInvocation') + } + + private byte[] compileWithIndexOnClasspath(String source, String className, String packageName) { + Path sourceFile = tempDir.resolve(className + '.groovy') + sourceFile.toFile().text = source + Path outputDir = Files.createDirectories(tempDir.resolve('out-' + className)) + + CompilerConfiguration configuration = new CompilerConfiguration() + configuration.targetDirectory = outputDir.toFile() + configuration.parameters = true + CompilationUnit unit = new CompilationUnit(configuration, null, new GroovyClassLoader( + new URLClassLoader([indexDir.toUri().toURL()] as URL[], getClass().classLoader))) + unit.addSource(sourceFile.toFile()) + unit.compile() + + Files.readAllBytes(outputDir.resolve(packageName.replace('.', '/')).resolve(className + '.class')) + } +} diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/GspStaticTagResolutionSpec.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/GspStaticTagResolutionSpec.groovy new file mode 100644 index 00000000000..4b6b2c43a15 --- /dev/null +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/GspStaticTagResolutionSpec.groovy @@ -0,0 +1,181 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.web.taglib + +import java.nio.file.Files +import java.nio.file.Path + +import org.grails.gsp.GroovyPagesTemplateEngine +import org.grails.taglib.index.TagLibraryIndex +import spock.lang.Specification +import spock.lang.TempDir + +/** + * With the framework tag libraries on the compile classpath, their compile-time descriptors let a GSP + * be checked against the tags that actually exist, rather than deferring every tag call to runtime + * dispatch. + */ +class GspStaticTagResolutionSpec extends Specification { + + @TempDir + Path tempDir + + GroovyPagesTemplateEngine gpte + + def setup() { + gpte = engineFor(null) + } + + /** + * The strictness and dynamic namespaces a build declares reach the compiler as a classpath + * resource written by the {@code generateTagLibraryIndex} task, so a compilation that is meant to + * see them is given a class loader that can. + */ + private GroovyPagesTemplateEngine engineFor(String settings) { + ClassLoader parent = getClass().classLoader + if (settings != null) { + Path settingsDir = Files.createTempDirectory(tempDir, 'settings') + Path indexDir = Files.createDirectories(settingsDir.resolve(TagLibraryIndex.INDEX_LOCATION)) + indexDir.resolve('compile-settings.properties').toFile().text = settings + parent = new URLClassLoader([settingsDir.toUri().toURL()] as URL[], parent) + } + GroovyPagesTemplateEngine engine = new GroovyPagesTemplateEngine() + engine.classLoader = parent + engine.afterPropertiesSet() + engine + } + + void 'the framework tag libraries are visible through their compile-time descriptors'() { + given: + TagLibraryIndex index = TagLibraryIndex.load(getClass().classLoader) + + expect: + index.hasNamespace('g') + index.lookup('g', 'message') != null + index.isKnown('g', 'link') + } + + void 'a statically compiled page calling a known tag compiles'() { + given: + String template = '''<%@ page compileStatic="true" %>${g.message(code: 'some.code')}''' + + when: + def t = gpte.createTemplate(template, 'known-tag') + + then: + t.metaInfo.compilationException == null + } + + void 'an unrecognised tag does not fail the build by default'() { + given: 'a namespace can hold tag libraries the index never saw, so absence is not a misspelling' + String template = '''<%@ page compileStatic="true" %>${g.mesage(code: 'typo')}''' + + when: + def t = gpte.createTemplate(template, 'unknown-tag-lenient') + + then: 'it resolves at runtime as it did before, with nothing reported' + t.metaInfo.compilationException == null + } + + void 'a page that has not declared compileStatic is never judged against the index'() { + given: 'such a page resolves the receiver against its model, which the build cannot see' + GroovyPagesTemplateEngine strict = engineFor('strictTags=true\n') + String template = '''${g.custom(code: 'from the model')}''' + + when: + def t = strict.createTemplate(template, 'dynamic-page-strict') + + then: 'reporting it would reject a call this release deliberately still allows' + t.metaInfo.compilationException == null + } + + void 'an unrecognised tag fails compilation when the build declares its tags complete'() { + given: 'g is a namespace this project describes, so what it holds is knowable' + GroovyPagesTemplateEngine strict = engineFor('strictTags=true\nlocalNamespaces=g\n') + String template = '''<%@ page compileStatic="true" %>${g.mesage(code: 'typo')}''' + + when: + def t = strict.createTemplate(template, 'unknown-tag-strict') + + then: 'the misspelling is reported when the page is compiled rather than when it renders' + t.metaInfo.compilationException != null + t.metaInfo.compilationException.message.contains('No such tag [mesage]') + t.metaInfo.compilationException.message.contains('namespace [g]') + } + + void 'an unrecognised tag in a namespace this project does not declare is never reported'() { + given: 'strict checking on, but g is filled in by tag libraries from elsewhere' + GroovyPagesTemplateEngine strict = engineFor('strictTags=true\n') + String template = '''<%@ page compileStatic="true" %>${g.mesage(code: 'typo')}''' + + when: + def t = strict.createTemplate(template, 'unknown-tag-foreign-namespace') + + then: 'how many of them carry descriptors is not knowable, so a missing tag proves nothing' + t.metaInfo.compilationException == null + } + + void 'an unrecognised tag in a declared dynamic namespace is never reported'() { + given: 'the build said this namespace is filled in while the application runs' + GroovyPagesTemplateEngine strict = engineFor('strictTags=true\ndynamicTagNamespaces=g\n') + String template = '''<%@ page compileStatic="true" %>${g.mesage(code: 'typo')}''' + + when: + def t = strict.createTemplate(template, 'dynamic-namespace-tag') + + then: + t.metaInfo.compilationException == null + } + + void 'a tag written as markup is checked against the same descriptions'() { + given: + GroovyPagesTemplateEngine strict = engineFor('strictTags=true\nlocalNamespaces=g\n') + String template = '''<%@ page compileStatic="true" %>''' + + when: + def t = strict.createTemplate(template, 'unknown-markup-tag') + + then: + t.metaInfo.compilationException != null + t.metaInfo.compilationException.message.contains('No such tag [mesage]') + } + + void 'a tag declared by two tag libraries is never reported as unknown'() { + given: 'ambiguity means the tag exists but which one runs is decided at runtime' + GroovyPagesTemplateEngine strict = engineFor('strictTags=true\n') + String template = '''<%@ page compileStatic="true" %>${g.link(controller: 'book')}''' + + when: + def t = strict.createTemplate(template, 'ambiguous-not-unknown') + + then: 'a resolvable tag still compiles when the build declares its tags complete' + t.metaInfo.compilationException == null + } + + void 'a namespace with no compiled tag library still resolves dynamically'() { + given: 'a namespace the index knows nothing about, as a runtime-registered tag library would be' + String template = '''<%@ page compileStatic="true" taglibs="somepluginns" %>${somepluginns.anything(a: 1)}''' + + when: + def t = gpte.createTemplate(template, 'unindexed-namespace') + + then: 'compilation succeeds and the call is left to runtime dispatch' + t.metaInfo.compilationException == null + } +} diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/IndexEdgeCaseTagLib.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/IndexEdgeCaseTagLib.groovy new file mode 100644 index 00000000000..c7c35af919f --- /dev/null +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/IndexEdgeCaseTagLib.groovy @@ -0,0 +1,65 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.web.taglib + +import grails.gsp.NotATag +import grails.gsp.Tag +import grails.gsp.TagLib + +/** + * Exercises the cases where "is this a tag" is decided by something other than the plain + * {@code (Map)} / {@code (Map, Closure)} shape, so that the compile-time index and the runtime + * agree on all of them. + */ +@TagLib +class IndexEdgeCaseTagLib extends BaseEdgeTagLib { + + static namespace = 'edge' + + /** Conventional attributes-only tag. */ + def plain(Map attrs) { 'plain' } + + /** Conventional tag taking a body. */ + def withBody(Map attrs, Closure body) { 'withBody' } + + /** Conventional shape, but explicitly excluded. */ + @NotATag + def excluded(Map attrs) { 'excluded' } + + /** Unconventional shape, but explicitly included, with attributes bound by parameter name. */ + @Tag + def annotated(Map attrs, String code) { code } + + /** Untyped attributes are not assignable to Map and so are not dispatchable. */ + def untyped(attrs) { 'untyped' } + + /** An ordinary helper that happens to live on the tag library. */ + String helper(String a, int b) { a } + + /** + * A Map parameter not named {@code attrs}. Runtime requires the name to be {@code attrs} whenever + * parameter names are retained, which this build does. + */ + def renamedAttrs(Map options) { 'renamedAttrs' } +} + +/** A tag declared on a base class, which reflection's declared-only scan does not see. */ +abstract class BaseEdgeTagLib { + def inherited(Map attrs) { 'inherited' } +} diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/RewrittenCallsTagLib.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/RewrittenCallsTagLib.groovy new file mode 100644 index 00000000000..19897343017 --- /dev/null +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/RewrittenCallsTagLib.groovy @@ -0,0 +1,52 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.web.taglib + +import grails.gsp.TagLib + +/** + * Calls other tags in each of the shapes the rewriter distinguishes, so that what it rewrites and what + * it leaves alone are both exercised through real rendering. + */ +@TagLib +class RewrittenCallsTagLib { + + static namespace = 'rewrite' + + /** A namespaced call with attributes: rewritten. */ + def viaNamespace(Map attrs) { + out << g.link(controller: 'book') + } + + /** A namespaced call carrying a body: rewritten. */ + def withBody(Map attrs) { + out << g.link(controller: 'book') { 'inside' } + } + + /** A namespace no compiled tag library declares: left to resolve at runtime. */ + def viaUnknownNamespace(Map attrs) { + out << 'fallback' + } + + /** Attributes assembled at runtime, so the call shape is not evident: left alone. */ + def viaComputedAttributes(Map attrs) { + Map linkAttrs = [controller: 'book'] + out << g.link(linkAttrs) + } +} diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/RewrittenPageRenderingSpec.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/RewrittenPageRenderingSpec.groovy new file mode 100644 index 00000000000..d4734f4c3c7 --- /dev/null +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/RewrittenPageRenderingSpec.groovy @@ -0,0 +1,92 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.web.taglib + +import grails.testing.web.taglib.TagLibUnitTest +import org.grails.plugins.web.taglib.ApplicationTagLib +import spock.lang.Specification + +/** + * A rewritten tag call has to render exactly what the dispatched one rendered - the same markup, the + * same encoding, the same handling of a body - and a page that has not given up dynamic resolution has + * to keep resolving names against the model it was given. + */ +class RewrittenPageRenderingSpec extends Specification implements TagLibUnitTest { + + private static final String STATIC = '<%@ page compileStatic="true" %>' + + void 'a rewritten expression renders what the dispatched one rendered'() { + expect: + applyTemplate(STATIC + '''${g.createLink(controller: 'book', action: 'show')}''') == + applyTemplate('''${g.createLink(controller: 'book', action: 'show')}''') + } + + void 'a rewritten expression carrying a body renders the body'() { + when: + String rendered = applyTemplate(STATIC + '''${g.link(controller: 'book') { 'inside' }}''') + + then: + rendered == applyTemplate('''${g.link(controller: 'book') { 'inside' }}''') + rendered.contains('inside') + } + + void 'a rewritten expression encodes its output the same way'() { + given: 'the output of a tag goes through the page codec, which the invocation must not bypass' + String markup = '''${g.message(code: 'nonexistent', default: 'bold')}''' + + expect: + applyTemplate(STATIC + markup) == applyTemplate(markup) + } + + void 'a rewritten expression whose attributes are built at runtime renders the same'() { + given: 'forwarded arguments are adapted by the same rules dynamic dispatch applies' + String markup = '''<% def attrs = [controller: 'book', action: 'show'] %>${g.createLink(attrs)}''' + + expect: + applyTemplate(STATIC + markup) == applyTemplate(markup) + } + + void 'a model attribute named after a namespace still wins in a page that is not compiled statically'() { + given: 'a page resolves a name against its model before any tag library, and always has' + Map model = [g: [createLink: { Map attrs -> 'from the model' }]] + + when: + String rendered = applyTemplate('''${g.createLink(controller: 'book')}''', model) + + then: + rendered == 'from the model' + } + + void 'a model supplied namespace answering to a name no tag library declares still renders'() { + given: 'the receiver is the model, so the name never had to be a tag' + Map model = [g: [custom: { Map attrs -> 'from the model' }]] + + when: + String rendered = applyTemplate('''${g.custom(code: 'x')}''', model) + + then: + rendered == 'from the model' + } + + void 'a tag written as markup renders the same in a statically compiled page'() { + expect: + applyTemplate(STATIC + '''''') == + applyTemplate('''''') + } +} diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/SameProjectTagResolutionSpec.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/SameProjectTagResolutionSpec.groovy new file mode 100644 index 00000000000..0a296e5c805 --- /dev/null +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/SameProjectTagResolutionSpec.groovy @@ -0,0 +1,129 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.web.taglib + +import java.nio.file.Files +import java.nio.file.Path + +import org.codehaus.groovy.control.CompilationUnit +import org.codehaus.groovy.control.CompilerConfiguration +import org.grails.taglib.index.TagLibraryIndexGenerator +import spock.lang.Specification +import spock.lang.TempDir + +/** + * A tag declared by the project being compiled has to be resolvable while that project compiles, not + * only once it is packaged. + * + *

This is what generating the index from source before compilation is for, and it is checked here + * end to end: an index is generated from a tag library source, placed on a compile classpath, and a + * controller calling that namespace is compiled against it. Whether the build wires the directory onto + * compileGroovy is asserted separately, in GenerateTagLibraryIndexTaskSpec; what is proved here is + * that doing so is sufficient for the compiler to resolve the call. + */ +class SameProjectTagResolutionSpec extends Specification { + + @TempDir + Path tempDir + + Path indexDir + + def setup() { + Path taglibSources = Files.createDirectories(tempDir.resolve('grails-app/taglib/demo')) + taglibSources.resolve('LocalTagLib.groovy').toFile().text = ''' + package demo + + import grails.gsp.TagLib + + @TagLib + class LocalTagLib { + static namespace = 'local' + def greeting(Map attrs) { 'hello' } + } + ''' + indexDir = Files.createDirectories(tempDir.resolve('build/generated/grails-taglibs')) + TagLibraryIndexGenerator.generate( + tempDir.resolve('grails-app/taglib').toFile(), indexDir.toFile(), true, 'UTF-8') + } + + void 'the index describes the tag library the project declares'() { + expect: 'otherwise the compilation below would pass for the wrong reason' + new File(indexDir.toFile(), 'META-INF/grails/taglibs/demo.LocalTagLib.properties').exists() + } + + void 'a controller resolves a tag its own project declares'() { + when: 'the generated index is on the classpath the controller is compiled against' + byte[] compiled = compileWithIndexOnClasspath(''' + package demo + + import grails.artefact.gsp.TagLibraryInvoker + + class LocalController implements TagLibraryInvoker { + def index() { + local.greeting(name: 'world') + } + } + ''', 'LocalController', 'demo') + + then: 'the call is compiled into an invocation rather than left to be dispatched' + new String(compiled, 'ISO-8859-1').contains('org/grails/taglib/CompiledTagInvocation') + } + + void 'without the index on the classpath the same call stays dynamic'() { + when: 'the index is not visible to the compiler, as before it was generated ahead of time' + byte[] compiled = compileWithoutIndex(''' + package demo + + import grails.artefact.gsp.TagLibraryInvoker + + class UnresolvedController implements TagLibraryInvoker { + def index() { + local.greeting(name: 'world') + } + } + ''', 'UnresolvedController', 'demo') + + then: 'which is what made a project unable to resolve its own tags' + !new String(compiled, 'ISO-8859-1').contains('org/grails/taglib/CompiledTagInvocation') + } + + private byte[] compileWithIndexOnClasspath(String source, String className, String packageName) { + compile(source, className, packageName, new GroovyClassLoader( + new URLClassLoader([indexDir.toUri().toURL()] as URL[], getClass().classLoader))) + } + + private byte[] compileWithoutIndex(String source, String className, String packageName) { + compile(source, className, packageName, new GroovyClassLoader(getClass().classLoader)) + } + + private byte[] compile(String source, String className, String packageName, GroovyClassLoader loader) { + Path sourceFile = tempDir.resolve(className + '.groovy') + sourceFile.toFile().text = source + Path outputDir = Files.createDirectories(tempDir.resolve('out-' + className)) + + CompilerConfiguration configuration = new CompilerConfiguration() + configuration.targetDirectory = outputDir.toFile() + configuration.parameters = true + CompilationUnit unit = new CompilationUnit(configuration, null, loader) + unit.addSource(sourceFile.toFile()) + unit.compile() + + Files.readAllBytes(outputDir.resolve(packageName.replace('.', '/')).resolve(className + '.class')) + } +} diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/TagCallShadowingSpec.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/TagCallShadowingSpec.groovy new file mode 100644 index 00000000000..f8ca7a5e65d --- /dev/null +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/TagCallShadowingSpec.groovy @@ -0,0 +1,236 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.web.taglib + +import java.nio.file.Files +import java.nio.file.Path + +import org.codehaus.groovy.control.CompilationUnit +import org.codehaus.groovy.control.CompilerConfiguration +import spock.lang.Specification +import spock.lang.TempDir +import spock.lang.Unroll + +/** + * A name that happens to match a tag library namespace is only a namespace when nothing else in scope + * has claimed it. + * + *

Rewriting a call on a local variable, a parameter or a field named {@code g} would silently send + * it to a tag library instead of the object the author meant, which is the one failure this rewriting + * must never produce. + */ +class TagCallShadowingSpec extends Specification { + + @TempDir + Path tempDir + + @Unroll + void 'a call on #description is not rewritten'() { + when: + byte[] compiled = compile(""" + import grails.artefact.gsp.TagLibraryInvoker + class ${className} implements TagLibraryInvoker { + ${member} + def index(${parameter}) { + ${body} + g.createLink(controller: 'book') + } + } + """, className) + + then: 'the author meant their own g, not the tag library namespace' + !references(compiled, 'org/grails/taglib/CompiledTagInvocation') + + where: + description | className | member | parameter | body + 'a local variable' | 'LocalShadow' | '' | '' | 'def g = new Expando(createLink: { Map a -> "x" })' + 'a parameter' | 'ParameterShadow' | '' | 'Object g'| '' + 'a field' | 'FieldShadow' | 'Object g' | '' | '' + 'a typed local' | 'TypedLocalShadow' | '' | '' | 'Object g = null' + } + + @Unroll + void 'a getter named like a namespace shadows it: #description'() { + when: + byte[] compiled = compile(''' + import grails.artefact.gsp.TagLibraryInvoker + class SUBJECT implements TagLibraryInvoker { + GETTER + def index() { + this.g.createLink(controller: 'book') + } + } + '''.replace('SUBJECT', className).replace('GETTER', getter), className) + + then: 'the getter answers to the name, so it is not the tag library namespace' + !references(compiled, 'org/grails/taglib/CompiledTagInvocation') + + where: + description | className | getter + 'a getX getter' | 'GetterShadow' | 'Object getG() { null }' + 'a boolean isX getter' | 'BooleanIsShadow' | 'boolean isG() { true }' + } + + void 'a namespace shadowed by an inherited getter is not rewritten'() { + when: + byte[] compiled = compile(''' + import grails.artefact.gsp.TagLibraryInvoker + class GetterBase { + Object getG() { null } + } + class InheritedGetterShadow extends GetterBase implements TagLibraryInvoker { + def index() { + this.g.createLink(controller: 'book') + } + } + ''', 'InheritedGetterShadow') + + then: + !references(compiled, 'org/grails/taglib/CompiledTagInvocation') + } + void 'an unqualified call inside a closure is left for the delegate'() { + when: 'the shape a controller writes for request.withFormat { form multipartForm { } }' + boolean compiled = compileAndScanAll(''' + import grails.artefact.gsp.TagLibraryInvoker + class DelegatingCaller implements TagLibraryInvoker { + def index() { + withSomething { + link(controller: 'book') + } + } + def withSomething(Closure body) { body() } + } + ''', 'DelegatingCaller') + + then: 'a closure is given a delegate when it runs, and the delegate may answer to the name' + !compiled + } + + void 'a namespaced call inside a closure is still rewritten'() { + when: 'the source named the tag library, so no delegate can claim it' + boolean compiled = compileAndScanAll(''' + import grails.artefact.gsp.TagLibraryInvoker + class QualifiedInClosureCaller implements TagLibraryInvoker { + def index() { + withSomething { + g.link(controller: 'book') + } + } + def withSomething(Closure body) { body() } + } + ''', 'QualifiedInClosureCaller') + + then: 'so a tag body and a withFormat block keep the faster path for the calls that are tags' + compiled + } + + void 'a call whose arguments are not a tag shape is not rewritten'() { + when: 'two arguments whose first is not a map, which a tag cannot be called with' + byte[] compiled = compile(''' + import grails.artefact.gsp.TagLibraryInvoker + class OverloadedCaller implements TagLibraryInvoker { + def index() { + g.createLink('2026-08-19', 'yyyy') + } + } + ''', 'OverloadedCaller') + + then: 'the invocation would drop both arguments, so the call is left to dispatch as it did' + !references(compiled, 'org/grails/taglib/CompiledTagInvocation') + } + + void 'a call on the namespace itself is still rewritten'() { + when: 'nothing in scope claims the name' + byte[] compiled = compile(''' + import grails.artefact.gsp.TagLibraryInvoker + class UnshadowedCaller implements TagLibraryInvoker { + def index() { + g.createLink(controller: 'book') + } + } + ''', 'UnshadowedCaller') + + then: + references(compiled, 'org/grails/taglib/CompiledTagInvocation') + } + + void 'a call in an inherited method is not rewritten through a subclass'() { + when: 'only the subclass can call tags; the superclass method is not its code to change' + byte[] compiled = compile(''' + import grails.artefact.gsp.TagLibraryInvoker + class PlainBase { + def helper() { + g.createLink(controller: 'book') + } + } + class TagAwareSubclass extends PlainBase implements TagLibraryInvoker { + def index() { helper() } + } + ''', 'PlainBase') + + then: 'the superclass class file is untouched' + !references(compiled, 'org/grails/taglib/CompiledTagInvocation') + } + + /** + * A closure body compiles into a class of its own, so a call written inside one is not in the + * enclosing class file. Everything the compilation emitted is scanned. + * + * @param source the source to compile + * @param className the class it declares + * @return whether any emitted class references the invocation entry point + */ + private boolean compileAndScanAll(String source, String className) { + Path sourceFile = tempDir.resolve(className + '.groovy') + sourceFile.toFile().text = source + Path outputDir = Files.createDirectories(tempDir.resolve('all-' + className)) + + CompilerConfiguration configuration = new CompilerConfiguration() + configuration.targetDirectory = outputDir.toFile() + configuration.parameters = true + CompilationUnit unit = new CompilationUnit(configuration, null, + new GroovyClassLoader(getClass().classLoader, configuration)) + unit.addSource(sourceFile.toFile()) + unit.compile() + + List emitted = Files.walk(outputDir).filter { it.toString().endsWith('.class') }.toList() + assert emitted.size() > 1, "expected a closure class alongside ${className}, got ${emitted*.fileName}" + emitted.any { references(Files.readAllBytes(it), 'org/grails/taglib/CompiledTagInvocation') } + } + + private static boolean references(byte[] classBytes, String internalName) { + new String(classBytes, 'ISO-8859-1').contains(internalName) + } + + private byte[] compile(String source, String className) { + Path sourceFile = tempDir.resolve(className + '.groovy') + sourceFile.toFile().text = source + Path outputDir = Files.createDirectories(tempDir.resolve('classes-' + className)) + + CompilerConfiguration configuration = new CompilerConfiguration() + configuration.targetDirectory = outputDir.toFile() + configuration.parameters = true + CompilationUnit unit = new CompilationUnit(configuration, null, + new GroovyClassLoader(getClass().classLoader, configuration)) + unit.addSource(sourceFile.toFile()) + unit.compile() + + Files.readAllBytes(outputDir.resolve(className + '.class')) + } +} diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/TagLibraryIndexAgreementSpec.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/TagLibraryIndexAgreementSpec.groovy new file mode 100644 index 00000000000..46fef2e6295 --- /dev/null +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/TagLibraryIndexAgreementSpec.groovy @@ -0,0 +1,119 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.web.taglib + +import org.grails.plugins.web.taglib.ApplicationTagLib +import org.grails.plugins.web.taglib.CountryTagLib +import org.grails.plugins.web.taglib.FormTagLib +import org.grails.plugins.web.taglib.FormatTagLib +import org.grails.plugins.web.taglib.JavascriptTagLib +import org.grails.plugins.web.taglib.PluginTagLib +import org.grails.plugins.web.taglib.UrlMappingTagLib +import org.grails.plugins.web.taglib.ValidationTagLib +import org.grails.taglib.TagMethodInvoker +import org.grails.taglib.index.TagLibraryIndex +import spock.lang.Specification +import spock.lang.Unroll + +/** + * The compile-time index and the runtime tag resolution must describe the same set of tags. + * + * A tag present in the index but not resolvable at runtime would let a GSP compile against it and + * then fail when the page renders, which is the failure mode that makes a static index dangerous. + */ +class TagLibraryIndexAgreementSpec extends Specification { + + static final List> FRAMEWORK_TAG_LIBRARIES = [ + ApplicationTagLib, ValidationTagLib, FormTagLib, FormatTagLib, + JavascriptTagLib, PluginTagLib, UrlMappingTagLib, CountryTagLib + ] + + TagLibraryIndex index = TagLibraryIndex.load(getClass().classLoader) + + void 'the index is populated from the compiled framework tag libraries'() { + expect: + !index.isEmpty() + 'g' in index.namespaces + } + + @Unroll + void 'index and runtime agree on the tags declared by #tagLibClass.simpleName'() { + given: 'the tags the runtime will dispatch for this tag library' + Set runtimeTags = TagMethodInvoker.getInvokableTagMethodNames(tagLibClass) as Set + + and: 'the tags the compile-time index records for it' + Set indexedTags = index.getTagNames(namespaceOf(tagLibClass)).findAll { String tag -> + index.lookup(namespaceOf(tagLibClass), tag).tagLibraryClassName() == tagLibClass.name + } as Set + + expect: 'no tag is claimed statically that the runtime would refuse to dispatch' + (indexedTags - runtimeTags).isEmpty() + + and: 'no runtime tag is missing from the index, which would silently fall back to dynamic' + (runtimeTags - indexedTags).isEmpty() + + where: + tagLibClass << FRAMEWORK_TAG_LIBRARIES + } + + void 'well known tags resolve through the index to their declaring tag library'() { + expect: + index.lookup('g', tag)?.tagLibraryClassName() == declaringClass.name + + where: + tag | declaringClass + 'message' | ValidationTagLib + 'fieldValue' | ValidationTagLib + 'link' | ApplicationTagLib + 'set' | ApplicationTagLib + 'formatDate' | FormatTagLib + } + + void 'index and runtime agree on tags decided by annotation or parameter type'() { + given: + Set runtimeTags = TagMethodInvoker.getInvokableTagMethodNames(IndexEdgeCaseTagLib) as Set + Set indexedTags = index.getTagNames('edge') + + expect: 'the two views are identical, including the awkward cases' + indexedTags == runtimeTags + + and: 'specifically' + 'plain' in indexedTags + 'withBody' in indexedTags + 'annotated' in indexedTags // @Tag overrides the signature rule + !('excluded' in indexedTags) // @NotATag overrides it the other way + !('untyped' in indexedTags) // untyped attrs is not Map-assignable, so not dispatchable + !('helper' in indexedTags) + } + + void 'an unknown tag is not resolved'() { + expect: + index.lookup('g', 'noSuchTagAnywhere') == null + index.lookup('nosuchnamespace', 'message') == null + } + + private static String namespaceOf(Class tagLibClass) { + def namespace = tagLibClass.declaredFields.find { it.name == 'namespace' && java.lang.reflect.Modifier.isStatic(it.modifiers) } + if (!namespace) { + return 'g' + } + namespace.accessible = true + (namespace.get(null) ?: 'g').toString() + } +} diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/TagLibraryInvokerDispatchSpec.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/TagLibraryInvokerDispatchSpec.groovy new file mode 100644 index 00000000000..54dd91426f6 --- /dev/null +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/TagLibraryInvokerDispatchSpec.groovy @@ -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.web.taglib + +import grails.artefact.gsp.TagLibraryInvoker +import grails.core.DefaultGrailsApplication +import grails.core.gsp.GrailsTagLibClass +import grails.gsp.TagLib +import grails.util.GrailsWebMockUtil +import org.grails.core.gsp.DefaultGrailsTagLibClass +import org.grails.taglib.TagLibraryLookup +import org.grails.web.servlet.mvc.GrailsWebRequest +import org.springframework.web.context.request.RequestContextHolder +import spock.lang.Specification + +/** + * What a class calling a tag through {@code methodMissing} gets back. + * + *

Resolving the tag used to end in {@code tagLibrary.invokeMethod(name, args)} - a direct call on + * the tag library bean. For a tag declared as a closure that reached the generated wrapper and so + * captured output; for one declared as a method there is no wrapper, so it called the method and + * returned whatever the method itself returned, with nothing captured. Dispatch now goes through the + * same capture for both, so a method-declared tag returns what it wrote rather than its return value. + * + *

That is the intended behaviour - the two forms of declaring a tag should not answer differently - + * but it is a change to a public trait, so it is pinned here. + */ +class TagLibraryInvokerDispatchSpec extends Specification { + + GrailsWebRequest webRequest + + def setup() { + // out resolves through the current request, so a tag that writes needs one bound. + webRequest = GrailsWebMockUtil.bindMockWebRequest() + } + + def cleanup() { + RequestContextHolder.resetRequestAttributes() + } + + void 'a method declared tag called unqualified returns what it wrote'() { + given: 'a class that can call tags but is not itself a tag library, as a controller is' + Caller caller = new Caller(tagLibraryLookup: newLookup()) + + when: 'the tag writes to out and returns something else entirely' + Object result = caller.callWriting() + + then: 'the captured output is the answer, not the return value of the method' + result.toString() == 'written' + } + + void 'a method declared tag receives the attributes it was called with'() { + given: + Caller caller = new Caller(tagLibraryLookup: newLookup()) + + when: + Object result = caller.callWithAttributes() + + then: + result.toString() == 'hello world' + } + + void 'a name that is both a tag and an overload reaches the overload'() { + given: 'format is declared as a tag and as an ordinary two argument method' + Caller caller = new Caller(tagLibraryLookup: newLookup()) + + when: 'called with the arguments only the overload can take' + Object result = caller.callOverload() + + then: 'the tag shape does not match, so the real method runs rather than the tag with nothing' + result == '2026-08-19/yyyy' + } + + void 'a name no tag library declares is still a missing method'() { + given: + Caller caller = new Caller(tagLibraryLookup: newLookup()) + + when: + caller.callUnknown() + + then: + thrown(MissingMethodException) + } + + private static TagLibraryLookup newLookup() { + TagLibraryLookup lookup = new TagLibraryLookup() { + @Override + protected void putTagLib(Map tags, String name, GrailsTagLibClass taglib) { + tags.put(name, taglib.newInstance()) + } + } + DefaultGrailsApplication application = + new DefaultGrailsApplication([DispatchTagLib] as Class[], TagLibraryLookup.classLoader) + application.initialise() + lookup.grailsApplication = application + lookup.registerTagLib(new DefaultGrailsTagLibClass(DispatchTagLib)) + lookup + } +} + +class Caller implements TagLibraryInvoker { + + Object callWriting() { + writes() + } + + Object callWithAttributes() { + greet(name: 'world') + } + + Object callUnknown() { + noSuchTagAnywhere() + } + + Object callOverload() { + format('2026-08-19', 'yyyy') + } +} + +@TagLib +class DispatchTagLib { + + static namespace = 'g' + + def writes(Map attrs) { + out << 'written' + 'a return value that is not the output' + } + + def greet(Map attrs) { + out << "hello ${attrs.name}" + } + + def format(Map attrs) { + out << 'as a tag' + } + + def format(String value, String pattern) { + "${value}/${pattern}" + } +} diff --git a/grails-mail/src/main/groovy/grails/plugins/mail/PlainTextMailTagLib.groovy b/grails-mail/src/main/groovy/grails/plugins/mail/PlainTextMailTagLib.groovy index 842b7f35c35..5e52cdc84a3 100644 --- a/grails-mail/src/main/groovy/grails/plugins/mail/PlainTextMailTagLib.groovy +++ b/grails-mail/src/main/groovy/grails/plugins/mail/PlainTextMailTagLib.groovy @@ -22,7 +22,7 @@ class PlainTextMailTagLib { static namespace = 'text' - def newLine = { + def newLine(Map attrs) { out << '\n' } } diff --git a/grails-mail/src/test/groovy/grails/plugins/mail/PlainTextMailTagLibSpec.groovy b/grails-mail/src/test/groovy/grails/plugins/mail/PlainTextMailTagLibSpec.groovy new file mode 100644 index 00000000000..e7e736ecbe6 --- /dev/null +++ b/grails-mail/src/test/groovy/grails/plugins/mail/PlainTextMailTagLibSpec.groovy @@ -0,0 +1,50 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package grails.plugins.mail + +import grails.testing.web.taglib.TagLibUnitTest +import spock.lang.Specification + +/** + * {@code text:newLine} is declared as a method rather than as a closure field. + * + *

Both forms dispatch, and this tag was not broken by compile-time tag resolution. It was converted + * because declaring a tag as a closure is deprecated as of this release and now warns when compiled, + * and the framework's own tag libraries should not trip a warning the framework introduces. + * + *

The tag had no test either way, which is why this exists: converting a published tag's signature + * without one is how a working tag stops working unnoticed. + */ +class PlainTextMailTagLibSpec extends Specification implements TagLibUnitTest { + + void 'the tag library declares the text namespace'() { + expect: + PlainTextMailTagLib.namespace == 'text' + } + + void 'newLine renders a newline'() { + expect: + applyTemplate('') == '\n' + } + + void 'newLine renders between surrounding content'() { + expect: + applyTemplate('ab') == 'a\nb' + } +} diff --git a/grails-test-examples/app1/grails-app/controllers/functionaltests/IncludesController.groovy b/grails-test-examples/app1/grails-app/controllers/functionaltests/IncludesController.groovy index ad2adb7b6de..54a08b1f05d 100644 --- a/grails-test-examples/app1/grails-app/controllers/functionaltests/IncludesController.groovy +++ b/grails-test-examples/app1/grails-app/controllers/functionaltests/IncludesController.groovy @@ -52,4 +52,17 @@ class IncludesController { def includeFromTemplateRenderingText() { render template:"textInclude" } + + /** + * A tag call written with its namespace, in a controller declared by convention. + * + *

Read by CompiledTagCallSpec, which asserts this compiled into a direct invocation. That is + * the claim the tag library index exists to make, and it holds only when the whole build wires + * together - index generated, packaged, on the compile classpath, transform applied - so it is + * checked here against a real build rather than a synthetic compilation. + */ + def compiledTagCallProbe() { + render g.createLink(controller: 'includes', action: 'viewRendering') + } + } diff --git a/grails-test-examples/app1/src/test/groovy/functionaltests/CompiledTagCallSpec.groovy b/grails-test-examples/app1/src/test/groovy/functionaltests/CompiledTagCallSpec.groovy new file mode 100644 index 00000000000..fc78cba817a --- /dev/null +++ b/grails-test-examples/app1/src/test/groovy/functionaltests/CompiledTagCallSpec.groovy @@ -0,0 +1,65 @@ +/* + * 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 functionaltests + +import spock.lang.Specification + +/** + * A tag call in a controller declared by convention is compiled into a direct invocation. + * + *

Everything else that asserts this compiles a source in isolation, which proves the transform + * works but not that a real project reaches it: the index has to be generated, packaged, placed on + * the compile classpath and read, and the transform has to run after the trait that makes the class + * able to call tags has been applied. This reads the class file this project actually produced. + * + *

It also covers ground a synthetic compilation cannot. An earlier spec drove the convention path + * by writing a source into a temporary {@code grails-app/controllers} directory; it passed on macOS + * and failed on Linux and Windows, because recognising a controller by its location depends on where + * the compilation happens. Reading a real build's output has no such dependence, so this answers the + * same question on every platform CI runs. + */ +class CompiledTagCallSpec extends Specification { + + void 'a namespaced tag call in a convention controller is compiled into an invocation'() { + given: + byte[] compiled = classBytes(IncludesController) + + expect: 'the probe method is the one carrying the call' + asText(compiled).contains('compiledTagCallProbe') + + and: 'and it reaches the tag through the invocation entry point rather than dynamically' + asText(compiled).contains('org/grails/taglib/CompiledTagInvocation') + } + + void 'a class that cannot call tags is left alone'() { + expect: 'so the assertion above is about tag calls, not about every class in the project' + !asText(classBytes(Book)).contains('org/grails/taglib/CompiledTagInvocation') + } + + private static String asText(byte[] bytes) { + new String(bytes, 'ISO-8859-1') + } + + private static byte[] classBytes(Class type) { + String resource = type.name.replace('.', '/') + '.class' + InputStream stream = type.classLoader.getResourceAsStream(resource) + assert stream != null, "no class file for ${type.name}" + stream.withCloseable { it.bytes } + } +} diff --git a/grails-testing-support-web/src/main/groovy/grails/testing/web/GrailsWebUnitTest.groovy b/grails-testing-support-web/src/main/groovy/grails/testing/web/GrailsWebUnitTest.groovy index f4ae0940c20..1fffb982228 100644 --- a/grails-testing-support-web/src/main/groovy/grails/testing/web/GrailsWebUnitTest.groovy +++ b/grails-testing-support-web/src/main/groovy/grails/testing/web/GrailsWebUnitTest.groovy @@ -115,6 +115,9 @@ trait GrailsWebUnitTest implements GrailsUnitTest { tagLookup.registerTagLib(tagLib) def taglibObject = applicationContext.getBean(tagLib.fullName) + // Kept for tests, which call tag methods directly: the installed methods substitute an empty + // body for a missing one, so tagLib.someTag(attrs, null) works. A running application does not + // rely on these, resolving tags through the lookup instead. TagLibraryMetaUtils.enhanceTagLibMetaClass(tagLib, tagLookup) TagLibraryMetaUtils.enhanceTagLibMetaClass(taglibObject.metaClass, tagLookup, tagLib.namespace) if (taglibObject instanceof TagLibrary) {