From dd8e3106447f69729e92ffce5a1a8466b13947f0 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Mon, 10 Aug 2026 19:57:08 -0500 Subject: [PATCH 01/74] Define tag handlers as methods in PlainTextMailTagLib The closure form is deprecated and carries no callable signature, so a tag defined that way cannot be resolved when a page is compiled. This was the last closure-based tag remaining in the repository. --- .../main/groovy/grails/plugins/mail/PlainTextMailTagLib.groovy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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' } } From 7c5b93fb1464ec9fb45de0fad07327f47f92873e Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Mon, 10 Aug 2026 19:57:08 -0500 Subject: [PATCH 02/74] Generate a tag library index at compile time Discovering which tags exist required loading every tag library and reflecting over it, which is only possible once the application is running. A GSP therefore had no way to know at compile time whether a tag call would resolve. The TagLib AST transformation now records each tag library's namespace and tag names as it is compiled, writing one descriptor per class under META-INF/grails/taglibs along with a manifest naming them. Descriptors are per class so that tag libraries packaged in separate jars merge on the classpath with no build step combining them, in the manner of META-INF/services entries. Deriving tag names from the AST has to agree exactly with the runtime rules in TagMethodInvoker, since a tag recorded in the index but rejected at runtime would resolve when a page is compiled and then fail when it renders. The framework method exclusions are shared rather than duplicated, and TagLibraryIndexAgreementSpec asserts the two views match for every framework tag library. Two cases the AST view has to account for: trait application generates super-accessor bridges that are synthetic at runtime but not marked so at canonicalization, and parameters with default values expand into overloads that reflection sees but the declaration does not show. --- .../org/grails/taglib/TagMethodInvoker.java | 11 +- .../grails/taglib/index/TagLibraryIndex.java | 194 +++++++++++++++++ .../taglib/index/TagLibraryIndexEntry.java | 30 +++ .../taglib/index/TagLibraryIndexWriter.java | 104 +++++++++ .../TagLibArtefactTypeAstTransformation.java | 33 +++ .../taglib/compiler/TagLibraryAstScanner.java | 197 ++++++++++++++++++ .../TagLibraryIndexAgreementSpec.groovy | 102 +++++++++ 7 files changed, 670 insertions(+), 1 deletion(-) create mode 100644 grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/index/TagLibraryIndex.java create mode 100644 grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/index/TagLibraryIndexEntry.java create mode 100644 grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/index/TagLibraryIndexWriter.java create mode 100644 grails-gsp/grails-web-taglib/src/main/groovy/grails/gsp/taglib/compiler/TagLibraryAstScanner.java create mode 100644 grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/TagLibraryIndexAgreementSpec.groovy 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..9b77276f348 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 @@ -45,7 +45,16 @@ 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. */ - private static final Set FRAMEWORK_METHOD_NAMES = Set.of( + /** + * 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 + */ + public static final Set FRAMEWORK_METHOD_NAMES = Set.of( "afterPropertiesSet", "currentRequestAttributes", "destroy", 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..77eeac3de97 --- /dev/null +++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/index/TagLibraryIndex.java @@ -0,0 +1,194 @@ +/* + * 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; + +/** + * 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/"; + + static final String NAMESPACE_KEY = "namespace"; + static final String CLASS_KEY = "class"; + static final String TAGS_KEY = "tags"; + + private final Map> byNamespace; + + private TagLibraryIndex(Map> byNamespace) { + this.byNamespace = byNamespace; + } + + /** + * 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<>(); + if (loader == null) { + return new TagLibraryIndex(merged); + } + // 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; + } + 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; + } + Map tagsForNamespace = + merged.computeIfAbsent(namespace, k -> new TreeMap<>()); + for (String tagName : tags.split(",")) { + String trimmed = tagName.trim(); + if (!trimmed.isEmpty()) { + // Later descriptors win, matching TagLibraryLookup.registerTagLib where a tag + // library registered afterwards replaces an earlier definition of the same tag. + tagsForNamespace.put(trimmed, new TagLibraryIndexEntry(namespace, trimmed, className)); + } + } + } + return new TagLibraryIndex(merged); + } + + 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()) { + Enumeration descriptors = loader.getResources(INDEX_LOCATION + className + ".properties"); + while (descriptors.hasMoreElements()) { + urls.add(descriptors.nextElement()); + } + } + } + } 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; + } + + 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) { + Map tags = byNamespace.get(namespace); + return tags != null ? tags.get(tagName) : null; + } + + /** + * @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(); + } + + /** + * @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..3c4974063d9 --- /dev/null +++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/index/TagLibraryIndexEntry.java @@ -0,0 +1,30 @@ +/* + * 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 + * @since 8.0.0 + */ +public record TagLibraryIndexEntry(String namespace, String tagName, String tagLibraryClassName) { +} 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..919df23a20c --- /dev/null +++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/index/TagLibraryIndexWriter.java @@ -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.taglib.index; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +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 { + + private TagLibraryIndexWriter() { + } + + /** + * 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.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); + + File manifest = new File(indexDirectory, "index.properties"); + Properties names = new Properties(); + if (manifest.isFile()) { + try (InputStream in = Files.newInputStream(manifest.toPath())) { + names.load(new InputStreamReader(in, StandardCharsets.UTF_8)); + } + } + names.setProperty(className, ""); + store(manifest, names); + } + + private static void store(File file, Properties properties) throws IOException { + // Properties.store stamps a comment with the current time, which would make output differ + // between builds; the entries are written directly instead to keep the descriptor stable. + StringBuilder text = new StringBuilder(); + for (String key : new TreeSet<>(properties.stringPropertyNames())) { + text.append(escape(key)).append('=').append(escape(properties.getProperty(key))).append('\n'); + } + try (OutputStream out = Files.newOutputStream(file.toPath()); + Writer writer = new OutputStreamWriter(out, StandardCharsets.UTF_8)) { + writer.write(text.toString()); + } + } + + private static String escape(String value) { + return value.replace("\\", "\\\\").replace("=", "\\=").replace(":", "\\:"); + } +} 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..e3bede4e8bd 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,8 @@ import grails.gsp.TagLib; import org.grails.compiler.injection.ArtefactTypeAstTransformation; +import org.grails.compiler.injection.GrailsASTUtils; +import org.grails.taglib.index.TagLibraryIndexWriter; @GroovyASTTransformation(phase = CompilePhase.CANONICALIZATION) public class TagLibArtefactTypeAstTransformation extends ArtefactTypeAstTransformation { @@ -45,9 +50,37 @@ public class TagLibArtefactTypeAstTransformation extends ArtefactTypeAstTransfor @Override protected String resolveArtefactType(SourceUnit sourceUnit, AnnotationNode annotationNode, ClassNode classNode) { addClosureTagDeprecationWarnings(sourceUnit, classNode); + writeIndexEntry(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) { + 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; + } + try { + TagLibraryIndexWriter.write(targetDirectory, classNode.getName(), + TagLibraryAstScanner.resolveNamespace(classNode), + TagLibraryAstScanner.findTagNames(classNode)); + } 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."); + } + } + @Override protected ClassNode getAnnotationType() { return MY_TYPE; diff --git a/grails-gsp/grails-web-taglib/src/main/groovy/grails/gsp/taglib/compiler/TagLibraryAstScanner.java b/grails-gsp/grails-web-taglib/src/main/groovy/grails/gsp/taglib/compiler/TagLibraryAstScanner.java new file mode 100644 index 00000000000..062897e10ef --- /dev/null +++ b/grails-gsp/grails-web-taglib/src/main/groovy/grails/gsp/taglib/compiler/TagLibraryAstScanner.java @@ -0,0 +1,197 @@ +/* + * 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.Collection; +import java.util.LinkedHashSet; +import java.util.List; +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.MethodNode; +import org.codehaus.groovy.ast.Parameter; +import org.codehaus.groovy.ast.expr.ConstantExpression; +import org.codehaus.groovy.ast.expr.Expression; +import org.codehaus.groovy.ast.expr.ListExpression; + +import org.grails.taglib.TagMethodInvoker; + +/** + * Reads a tag library's namespace and tag names from its AST. + * + *

This mirrors the runtime rules in {@code TagMethodInvoker.isTagMethodCandidate} and + * {@code DefaultGrailsTagLibClass}, applied to {@link MethodNode} instead of {@code java.lang.reflect + * .Method} so that the answer is available while the tag library is being compiled. The two must agree: + * a tag recorded here but rejected at runtime would resolve statically and then fail to dispatch. + * + * @since 8.0.0 + */ +final class TagLibraryAstScanner { + + static final String DEFAULT_NAMESPACE = "g"; + + private static final String NAMESPACE_FIELD = "namespace"; + + private static final ClassNode CLOSURE_TYPE = ClassHelper.make(Closure.class); + private static final ClassNode MAP_TYPE = ClassHelper.MAP_TYPE; + + /** + * Names excluded because they are Groovy or Object plumbing rather than tags. The + * framework-trait names come from {@link TagMethodInvoker#FRAMEWORK_METHOD_NAMES} so that the + * compile-time and runtime views of "what is a tag" cannot drift apart. + */ + private static final Set NON_TAG_METHOD_NAMES = Set.of( + "invokeMethod", "methodMissing", "propertyMissing", "getProperty", "setProperty", + "getMetaClass", "setMetaClass", "equals", "hashCode", "toString"); + + private TagLibraryAstScanner() { + } + + /** + * @param classNode the tag library + * @return the declared {@code static namespace}, or {@value #DEFAULT_NAMESPACE} when absent + */ + static String resolveNamespace(ClassNode classNode) { + FieldNode namespaceField = classNode.getDeclaredField(NAMESPACE_FIELD); + if (namespaceField != null && namespaceField.isStatic()) { + Expression initial = namespaceField.getInitialExpression(); + if (initial instanceof ConstantExpression constant && constant.getValue() != null) { + String value = constant.getValue().toString().trim(); + if (!value.isEmpty()) { + return value; + } + } + } + return DEFAULT_NAMESPACE; + } + + /** + * @param classNode the tag library + * @return every tag the library declares, whether as a tag method or a legacy closure field + */ + static Collection findTagNames(ClassNode classNode) { + Set tagNames = new LinkedHashSet<>(); + for (MethodNode method : classNode.getMethods()) { + if (isTagMethodCandidate(method)) { + tagNames.add(method.getName()); + } + } + // Closure-typed fields remain tags for as long as the deprecated form is supported. + for (FieldNode field : classNode.getFields()) { + if (!field.isStatic() && field.getType() != null && CLOSURE_TYPE.equals(field.getType())) { + tagNames.add(field.getName()); + } + } + return tagNames; + } + + private static boolean isTagMethodCandidate(MethodNode method) { + if (!method.isPublic() || method.isStatic() || method.isAbstract() || method.isSynthetic()) { + return false; + } + String name = method.getName(); + if (name.startsWith("<") || NON_TAG_METHOD_NAMES.contains(name) || + TagMethodInvoker.FRAMEWORK_METHOD_NAMES.contains(name)) { + return false; + } + // Trait application generates super-accessor bridges such as + // "grails_artefact_TagLibrarytrait$super$raw". These are synthetic at runtime but are not + // flagged as such on the AST at canonicalization, so they are excluded by name. + if (name.indexOf('$') >= 0) { + return false; + } + Parameter[] parameters = method.getParameters(); + if (name.startsWith("get") && parameters.length == 0) { + return false; + } + if (name.startsWith("is") && parameters.length == 0) { + return false; + } + if (name.startsWith("set") && parameters.length == 1) { + return false; + } + return hasConventionalTagSignature(parameters); + } + + /** + * A tag is invoked as {@code (Map)} or {@code (Map, Closure)}; anything else is a helper method + * that happens to live on the tag library. + * + *

Parameters with default values are taken into account. Groovy expands + * {@code formatValue(value, String path = null, Boolean tagSyntaxCall = false)} into overloads of + * one, two and three arguments, and the runtime sees those overloads through reflection. At + * canonicalization only the declaration exists, so the arities the defaults will produce are + * derived here instead; otherwise such a tag is recorded as absent and silently falls back to + * dynamic dispatch. + */ + private static boolean hasConventionalTagSignature(Parameter[] parameters) { + int required = 0; + for (Parameter parameter : parameters) { + if (!parameter.hasInitialExpression()) { + required++; + } + } + // Every arity from the required count up to the full parameter list is callable. + for (int arity = Math.max(required, 1); arity <= parameters.length; arity++) { + if (matchesTagShape(parameters, arity)) { + return true; + } + } + return false; + } + + private static boolean matchesTagShape(Parameter[] parameters, int arity) { + if (arity == 1) { + return isMap(parameters[0]); + } + if (arity == 2) { + return isMap(parameters[0]) && isClosure(parameters[1]); + } + return false; + } + + private static boolean isMap(Parameter parameter) { + ClassNode type = parameter.getType(); + return type == null || ClassHelper.isObjectType(type) || type.isDerivedFrom(MAP_TYPE) || + MAP_TYPE.equals(type) || type.implementsInterface(MAP_TYPE); + } + + private static boolean isClosure(Parameter parameter) { + ClassNode type = parameter.getType(); + return type == null || ClassHelper.isObjectType(type) || CLOSURE_TYPE.equals(type) || + type.isDerivedFrom(CLOSURE_TYPE); + } + + /** + * @param expression a {@code static returnObjectForTags} initialiser + * @return the listed tag names + */ + static List constantList(Expression expression) { + if (expression instanceof ListExpression list) { + return list.getExpressions().stream() + .filter(ConstantExpression.class::isInstance) + .map(e -> String.valueOf(((ConstantExpression) e).getValue())) + .toList(); + } + return List.of(); + } +} 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..41341509dea --- /dev/null +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/TagLibraryIndexAgreementSpec.groovy @@ -0,0 +1,102 @@ +/* + * 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 '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() + } +} From 8afe580f86f30432b8a813723a57e2e53d82d5bc Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Mon, 10 Aug 2026 19:57:08 -0500 Subject: [PATCH 03/74] Resolve GSP tag calls against the compile-time index The type checking extension answered every unresolved tag call with makeDynamic, so compileStatic on a GSP verified model fields and left tag calls exactly as dynamic as they were without it. Tag calls are now checked against the tag library index. A call into a namespace backed by a compiled tag library must name a tag that library declares, and a misspelling is reported when the page is compiled rather than surfacing as a missing method when it renders. Namespaces the index does not know, as a tag library registered at runtime or supplied by a separately compiled plugin would be, keep resolving dynamically. Namespaces contributed by compiled tag libraries no longer have to be declared through the taglibs directive, because the index already states which tags they hold. --- .../GroovyPageTypeCheckingExtension.groovy | 50 ++++++++++ .../taglib/GspStaticTagResolutionSpec.groovy | 95 +++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/GspStaticTagResolutionSpec.groovy 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..5ec372765f2 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,17 @@ 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 turns a call to a misspelled tag from something + * deferred to runtime dispatch into a compilation error, and removes the need to declare + * namespaces by hand for tag libraries that were on the compile classpath. + */ + private static final TagLibraryIndex TAG_LIBRARY_INDEX = TagLibraryIndex.load( + GroovyPageTypeCheckingExtension.classLoader) + @Override Object run() { ClassNode configAnnotationClassNode = ClassHelper.make(GroovyPageTypeCheckingConfig) @@ -56,6 +70,8 @@ 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(TAG_LIBRARY_INDEX.namespaces) } unresolvedProperty { PropertyExpression pe -> @@ -74,6 +90,9 @@ class GroovyPageTypeCheckingExtension extends GroovyTypeCheckingExtensionSupport methodNotFound { receiver, name, argList, argTypes, call -> if (isThisTheReceiver(call)) { + // An unqualified call in a GSP is a tag in the default namespace. When that namespace + // has compiled tag libraries, the tag has to be one of them. + reportUnknownTagIfIndexed(GroovyPage.DEFAULT_NAMESPACE, name, call) return makeDynamic(call) } def objectExpression = call.objectExpression @@ -81,12 +100,14 @@ class GroovyPageTypeCheckingExtension extends GroovyTypeCheckingExtensionSupport return null } if (currentScope.dynamicProperties.contains(objectExpression)) { + reportUnknownTagIfIndexed(namespaceNameOf(objectExpression), name, call) return makeDynamic(call) } // GROOVY-12041: Groovy 5 resolves receivers inherited through getProperty(String) as dynamic // before unresolvedVariable/unresolvedProperty can record them. Use the marker Groovy places on // those expressions, but still require the receiver name to be an allowed taglib namespace. if (isAllowedDynamicTaglibNamespace(objectExpression)) { + reportUnknownTagIfIndexed(namespaceNameOf(objectExpression), name, call) return makeDynamic(call) } if (objectExpression instanceof VariableExpression && isUndeclaredDynamicVariable(objectExpression)) { @@ -146,4 +167,33 @@ class GroovyPageTypeCheckingExtension extends GroovyTypeCheckingExtensionSupport def isThisTheReceiver(expr) { expr.implicitThis || (expr.objectExpression instanceof VariableExpression && expr.objectExpression.thisExpression) } + + /** + * Fails compilation when a namespace has compiled tag libraries but none of them declares the tag. + * + *

Silent when the namespace is unknown to the index, because a tag library registered at runtime + * or supplied by a plugin compiled separately is still legitimate and must keep resolving + * dynamically. + */ + private void reportUnknownTagIfIndexed(String namespace, String tagName, Expression call) { + if (namespace == null || !TAG_LIBRARY_INDEX.hasNamespace(namespace)) { + return + } + if (TAG_LIBRARY_INDEX.lookup(namespace, tagName) != null) { + return + } + typeCheckingVisitor.addStaticTypeError( + "No such tag [${tagName}] in namespace [${namespace}]. Known tags: " + + TAG_LIBRARY_INDEX.getTagNames(namespace).join(', '), call) + } + + private static String namespaceNameOf(Expression objectExpression) { + if (objectExpression instanceof VariableExpression) { + return ((VariableExpression) objectExpression).name + } + if (objectExpression instanceof PropertyExpression) { + return ((PropertyExpression) objectExpression).propertyAsString + } + null + } } 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..0647e7e7e0e --- /dev/null +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/GspStaticTagResolutionSpec.groovy @@ -0,0 +1,95 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.web.taglib + +import org.grails.gsp.GroovyPagesTemplateEngine +import org.grails.taglib.index.TagLibraryIndex +import spock.lang.Specification + +/** + * With the framework tag libraries on the compile classpath, their compile-time descriptors let a + * statically compiled GSP be checked against the tags that actually exist, rather than deferring every + * tag call to runtime dispatch. + */ +class GspStaticTagResolutionSpec extends Specification { + + GroovyPagesTemplateEngine gpte + + def setup() { + gpte = new GroovyPagesTemplateEngine() + gpte.afterPropertiesSet() + } + + 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.lookup('g', 'link') != null + } + + 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 'a statically compiled page calling an unknown tag fails to compile'() { + given: + String template = '''<%@ page compileStatic="true" %>${g.mesage(code: 'typo')}''' + + when: + def t = gpte.createTemplate(template, 'unknown-tag') + + 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 unknown tag in the default namespace fails to compile'() { + given: + String template = '''<%@ page compileStatic="true" %>${mesage(code: 'typo')}''' + + when: + def t = gpte.createTemplate(template, 'unknown-default-ns-tag') + + then: + t.metaInfo.compilationException != null + t.metaInfo.compilationException.message.contains('No such tag [mesage]') + } + + 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 + } +} From c992f603e0c19676bca719a4eff5c9083468dc24 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Mon, 10 Aug 2026 21:03:50 -0500 Subject: [PATCH 04/74] Precompute tag method argument binding Dispatching a tag read Method.getParameters() on every invocation to work out which parameter takes the attribute map, which takes the body, and which are bound from named attributes. That allocates a fresh Parameter array and materialises reflection metadata each time, and it showed up directly in profiles of tag-heavy pages, yet the answer is fixed for a given method. The classification is now computed once, when the tag library class is first seen, and held alongside the method. Invocation walks the precomputed plan instead of re-reading reflection metadata, and the access check is suppressed once rather than paid per call. Also corrects two disagreements between the compile-time index and runtime dispatch that the framework tag libraries did not exercise: @Tag and @NotATag override the conventional signature rule at runtime and now do so when scanning the AST, and an attributes parameter has to be assignable to Map, so an untyped parameter is not a dispatchable tag and is no longer recorded as one. IndexEdgeCaseTagLib covers both directions. --- .../org/grails/taglib/TagMethodInvoker.java | 141 +++++++++++++----- .../taglib/compiler/TagLibraryAstScanner.java | 30 +++- .../web/taglib/IndexEdgeCaseTagLib.groovy | 54 +++++++ .../TagLibraryIndexAgreementSpec.groovy | 17 +++ 4 files changed, 198 insertions(+), 44 deletions(-) create mode 100644 grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/IndexEdgeCaseTagLib.groovy 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 9b77276f348..fd1e0e33a71 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 @@ -106,16 +106,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 @@ -127,12 +127,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() { } @@ -162,20 +253,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) { @@ -262,32 +353,4 @@ private static String signature(Method method) { return builder.append(')').toString(); } - private static Object[] toMethodArguments(Method method, 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-web-taglib/src/main/groovy/grails/gsp/taglib/compiler/TagLibraryAstScanner.java b/grails-gsp/grails-web-taglib/src/main/groovy/grails/gsp/taglib/compiler/TagLibraryAstScanner.java index 062897e10ef..3be6fbe92c0 100644 --- a/grails-gsp/grails-web-taglib/src/main/groovy/grails/gsp/taglib/compiler/TagLibraryAstScanner.java +++ b/grails-gsp/grails-web-taglib/src/main/groovy/grails/gsp/taglib/compiler/TagLibraryAstScanner.java @@ -33,6 +33,8 @@ import org.codehaus.groovy.ast.expr.Expression; import org.codehaus.groovy.ast.expr.ListExpression; +import grails.gsp.NotATag; +import grails.gsp.Tag; import org.grails.taglib.TagMethodInvoker; /** @@ -53,6 +55,8 @@ final class TagLibraryAstScanner { 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); /** * Names excluded because they are Groovy or Object plumbing rather than tags. The @@ -108,6 +112,13 @@ private static boolean isTagMethodCandidate(MethodNode method) { if (!method.isPublic() || method.isStatic() || method.isAbstract() || method.isSynthetic()) { return false; } + // @NotATag and @Tag override the signature rule at runtime, so they must override it here too. + if (!method.getAnnotations(NOT_A_TAG_ANNOTATION).isEmpty()) { + return false; + } + if (!method.getAnnotations(TAG_ANNOTATION).isEmpty()) { + return true; + } String name = method.getName(); if (name.startsWith("<") || NON_TAG_METHOD_NAMES.contains(name) || TagMethodInvoker.FRAMEWORK_METHOD_NAMES.contains(name)) { @@ -161,7 +172,7 @@ private static boolean hasConventionalTagSignature(Parameter[] parameters) { private static boolean matchesTagShape(Parameter[] parameters, int arity) { if (arity == 1) { - return isMap(parameters[0]); + return isMap(parameters[0]) || isClosure(parameters[0]); } if (arity == 2) { return isMap(parameters[0]) && isClosure(parameters[1]); @@ -169,16 +180,25 @@ private static boolean matchesTagShape(Parameter[] parameters, int arity) { return false; } + /** + * Mirrors {@code TagMethodInvoker.isAttrsParameter}, which requires the declared type to be + * assignable to {@link java.util.Map}. An untyped parameter is {@code Object} and is therefore not + * an attributes parameter, so a tag declared as {@code def foo(attrs)} is not dispatchable and must + * not be recorded here either. + */ private static boolean isMap(Parameter parameter) { ClassNode type = parameter.getType(); - return type == null || ClassHelper.isObjectType(type) || type.isDerivedFrom(MAP_TYPE) || - MAP_TYPE.equals(type) || type.implementsInterface(MAP_TYPE); + return type != null && (MAP_TYPE.equals(type) || type.isDerivedFrom(MAP_TYPE) || + type.implementsInterface(MAP_TYPE)); } + /** + * Mirrors {@code TagMethodInvoker.isBodyParameter}, which requires assignability to + * {@link groovy.lang.Closure}. + */ private static boolean isClosure(Parameter parameter) { ClassNode type = parameter.getType(); - return type == null || ClassHelper.isObjectType(type) || CLOSURE_TYPE.equals(type) || - type.isDerivedFrom(CLOSURE_TYPE); + return type != null && (CLOSURE_TYPE.equals(type) || type.isDerivedFrom(CLOSURE_TYPE)); } /** 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..bef354bf841 --- /dev/null +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/IndexEdgeCaseTagLib.groovy @@ -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. + */ +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 { + + 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 } +} 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 index 41341509dea..46fef2e6295 100644 --- 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 @@ -85,6 +85,23 @@ class TagLibraryIndexAgreementSpec extends Specification { '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 From 90075c3c8bc1152c168abbb06953edc942dfab20 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Mon, 10 Aug 2026 21:06:51 -0500 Subject: [PATCH 05/74] Cover tag library index merging across jars The index is written per tag library class specifically so that libraries packaged in separate jars combine on the classpath without a build step merging them. That is the central claim of the format and was previously only exercised indirectly, through tag libraries that all happened to live in one module. Builds classpaths out of temporary jars and asserts that two jars contributing to one namespace merge, that distinct namespaces stay distinct, that an empty classpath yields an empty index rather than failing, and that a malformed descriptor leaves its tags unknown so they fall back to dynamic resolution. --- .../taglib/index/TagLibraryIndexSpec.groovy | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 grails-gsp/grails-taglib/src/test/groovy/org/grails/taglib/index/TagLibraryIndexSpec.groovy 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..b63fbf203d5 --- /dev/null +++ b/grails-gsp/grails-taglib/src/test/groovy/org/grails/taglib/index/TagLibraryIndexSpec.groovy @@ -0,0 +1,124 @@ +/* + * 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 '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) + } + + 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')) + jar.write("class=${className}\nnamespace=${namespaceAndTags[0]}\ntags=${namespaceAndTags[1]}\n".bytes) + jar.closeEntry() + } + } + path + } +} From f621337ae6744fbcb66679233226e008178e965b Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Mon, 10 Aug 2026 21:26:32 -0500 Subject: [PATCH 06/74] Make the tag index agree with runtime in three more cases A design review found the compile-time index and runtime dispatch disagreeing in ways the framework tag libraries never exercise. Each would let a page compile and then fail as it renders. An attributes parameter is only recognised at runtime when it is named "attrs", unless the class was compiled without parameter names, in which case any name is accepted. The scanner checked only the type, so a tag written as foo(Map options) was recorded but is not dispatchable. Whether names are retained is read from the compiler configuration and the same rule applied, with the body parameter treated the same way. TagMethodInvoker scans declared methods, so a tag inherited from a base class is not dispatchable. The scanner walked inherited methods too and is now restricted to declarations on the tag library itself. Trait methods are woven as declarations and remain visible. A namespace is read at runtime through the class hierarchy and after its initialiser has run. The scanner looked only at the class itself and treated anything other than a constant as the default namespace, filing those tags under "g". It now walks the hierarchy, and when the namespace cannot be known without running the code the tag library is left out of the index rather than filed under a guess. An unrecognised tag is now a warning rather than a compilation error. The index describes the tag libraries compiled before a page, so a tag added without rebuilding its library, or a library registered at runtime, would otherwise fail a build whose pages are correct. Setting grails.views.gsp.strictTagChecking restores the error. --- .../GroovyPageTypeCheckingExtension.groovy | 32 +++++++++- .../TagLibArtefactTypeAstTransformation.java | 15 ++++- .../taglib/compiler/TagLibraryAstScanner.java | 63 ++++++++++++++----- .../taglib/GspStaticTagResolutionSpec.groovy | 26 ++++---- .../web/taglib/IndexEdgeCaseTagLib.groovy | 13 +++- 5 files changed, 116 insertions(+), 33 deletions(-) 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 5ec372765f2..736f78c6945 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 @@ -29,6 +29,8 @@ import org.codehaus.groovy.ast.expr.ListExpression import org.codehaus.groovy.ast.expr.PropertyExpression import org.codehaus.groovy.ast.expr.VariableExpression import org.codehaus.groovy.transform.stc.GroovyTypeCheckingExtensionSupport +import org.codehaus.groovy.control.SourceUnit +import org.codehaus.groovy.control.messages.WarningMessage import org.codehaus.groovy.transform.stc.StaticTypesMarker import org.grails.gsp.GroovyPage @@ -53,6 +55,19 @@ class GroovyPageTypeCheckingExtension extends GroovyTypeCheckingExtensionSupport private static final TagLibraryIndex TAG_LIBRARY_INDEX = TagLibraryIndex.load( GroovyPageTypeCheckingExtension.classLoader) + /** + * Turns an unrecognised tag from a warning into a compilation error. Off by default: the index + * holds the tag libraries compiled before this page, so a stale or partial index would fail a + * build whose pages are correct. + */ + public static final String STRICT_TAG_CHECKING_PROPERTY = 'grails.views.gsp.strictTagChecking' + + private static boolean isStrictTagChecking() { + // Read per report rather than cached: this is only reached once a tag has already failed to + // resolve, so it costs nothing on the common path and stays settable within a running compiler. + Boolean.getBoolean(STRICT_TAG_CHECKING_PROPERTY) + } + @Override Object run() { ClassNode configAnnotationClassNode = ClassHelper.make(GroovyPageTypeCheckingConfig) @@ -182,9 +197,20 @@ class GroovyPageTypeCheckingExtension extends GroovyTypeCheckingExtensionSupport if (TAG_LIBRARY_INDEX.lookup(namespace, tagName) != null) { return } - typeCheckingVisitor.addStaticTypeError( - "No such tag [${tagName}] in namespace [${namespace}]. Known tags: " + - TAG_LIBRARY_INDEX.getTagNames(namespace).join(', '), call) + String message = "No such tag [${tagName}] in namespace [${namespace}]. Known tags: " + + TAG_LIBRARY_INDEX.getTagNames(namespace).join(', ') + if (isStrictTagChecking()) { + typeCheckingVisitor.addStaticTypeError(message, call) + return + } + // A warning by default. The index reflects the tag libraries present when this page is + // compiled, and a tag added to an existing namespace without a rebuild of that library, or a + // library registered at runtime, would otherwise fail a build that is in fact correct. + // Set the system property to turn the warning into an error once a build regenerates the + // index reliably. + SourceUnit sourceUnit = typeCheckingVisitor.sourceUnit + sourceUnit?.errorCollector?.addWarning( + new WarningMessage(WarningMessage.LIKELY_ERRORS, message, null, sourceUnit)) } private static String namespaceNameOf(Expression objectExpression) { 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 e3bede4e8bd..3576bf76a50 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 @@ -70,10 +70,19 @@ protected void writeIndexEntry(SourceUnit sourceUnit, ClassNode classNode) { // descriptor; those callers resolve tags at runtime. return; } + String namespace = TagLibraryAstScanner.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(), - TagLibraryAstScanner.resolveNamespace(classNode), - TagLibraryAstScanner.findTagNames(classNode)); + TagLibraryIndexWriter.write(targetDirectory, classNode.getName(), namespace, + TagLibraryAstScanner.findTagNames(classNode, parameterNamesRetained)); } catch (IOException | RuntimeException e) { GrailsASTUtils.warning(sourceUnit, classNode, "Could not write the tag library index entry for [" + classNode.getName() + "]: " + diff --git a/grails-gsp/grails-web-taglib/src/main/groovy/grails/gsp/taglib/compiler/TagLibraryAstScanner.java b/grails-gsp/grails-web-taglib/src/main/groovy/grails/gsp/taglib/compiler/TagLibraryAstScanner.java index 3be6fbe92c0..3f5735e1cd9 100644 --- a/grails-gsp/grails-web-taglib/src/main/groovy/grails/gsp/taglib/compiler/TagLibraryAstScanner.java +++ b/grails-gsp/grails-web-taglib/src/main/groovy/grails/gsp/taglib/compiler/TagLibraryAstScanner.java @@ -71,19 +71,29 @@ private TagLibraryAstScanner() { } /** + * 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 declared {@code static namespace}, or {@value #DEFAULT_NAMESPACE} when absent + * @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 */ static String resolveNamespace(ClassNode classNode) { - FieldNode namespaceField = classNode.getDeclaredField(NAMESPACE_FIELD); - if (namespaceField != null && namespaceField.isStatic()) { + 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(); - if (!value.isEmpty()) { - return value; - } + 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; } @@ -92,10 +102,16 @@ static String resolveNamespace(ClassNode classNode) { * @param classNode the tag library * @return every tag the library declares, whether as a tag method or a legacy closure field */ - static Collection findTagNames(ClassNode classNode) { + static Collection findTagNames(ClassNode classNode, boolean parameterNamesRetained) { Set tagNames = new LinkedHashSet<>(); for (MethodNode method : classNode.getMethods()) { - if (isTagMethodCandidate(method)) { + // TagMethodInvoker scans getDeclaredMethods(), so a method inherited from a superclass is + // not dispatchable and must not be recorded. Trait methods are woven as declarations on the + // implementing class and so are still seen here. + if (method.getDeclaringClass() != null && !classNode.equals(method.getDeclaringClass())) { + continue; + } + if (isTagMethodCandidate(method, parameterNamesRetained)) { tagNames.add(method.getName()); } } @@ -108,7 +124,7 @@ static Collection findTagNames(ClassNode classNode) { return tagNames; } - private static boolean isTagMethodCandidate(MethodNode method) { + private static boolean isTagMethodCandidate(MethodNode method, boolean parameterNamesRetained) { if (!method.isPublic() || method.isStatic() || method.isAbstract() || method.isSynthetic()) { return false; } @@ -140,7 +156,7 @@ private static boolean isTagMethodCandidate(MethodNode method) { if (name.startsWith("set") && parameters.length == 1) { return false; } - return hasConventionalTagSignature(parameters); + return hasConventionalTagSignature(parameters, parameterNamesRetained); } /** @@ -154,7 +170,7 @@ private static boolean isTagMethodCandidate(MethodNode method) { * derived here instead; otherwise such a tag is recorded as absent and silently falls back to * dynamic dispatch. */ - private static boolean hasConventionalTagSignature(Parameter[] parameters) { + private static boolean hasConventionalTagSignature(Parameter[] parameters, boolean parameterNamesRetained) { int required = 0; for (Parameter parameter : parameters) { if (!parameter.hasInitialExpression()) { @@ -163,19 +179,19 @@ private static boolean hasConventionalTagSignature(Parameter[] parameters) { } // Every arity from the required count up to the full parameter list is callable. for (int arity = Math.max(required, 1); arity <= parameters.length; arity++) { - if (matchesTagShape(parameters, arity)) { + if (matchesTagShape(parameters, arity, parameterNamesRetained)) { return true; } } return false; } - private static boolean matchesTagShape(Parameter[] parameters, int arity) { + private static boolean matchesTagShape(Parameter[] parameters, int arity, boolean parameterNamesRetained) { if (arity == 1) { - return isMap(parameters[0]) || isClosure(parameters[0]); + return isAttrs(parameters[0], parameterNamesRetained) || isBody(parameters[0], parameterNamesRetained); } if (arity == 2) { - return isMap(parameters[0]) && isClosure(parameters[1]); + return isAttrs(parameters[0], parameterNamesRetained) && isBody(parameters[1], parameterNamesRetained); } return false; } @@ -186,6 +202,23 @@ private static boolean matchesTagShape(Parameter[] parameters, int arity) { * an attributes parameter, so a tag declared as {@code def foo(attrs)} is not dispatchable and must * not be recorded here either. */ + private static boolean isAttrs(Parameter parameter, boolean parameterNamesRetained) { + return isMap(parameter) && namedOrUnnamed(parameter, "attrs", parameterNamesRetained); + } + + private static boolean isBody(Parameter parameter, boolean parameterNamesRetained) { + return isClosure(parameter) && namedOrUnnamed(parameter, "body", parameterNamesRetained); + } + + /** + * Runtime accepts a parameter as attrs or body when it carries the expected name, or when the class + * was compiled without retaining parameter names and no name is available to check. Mirroring that + * requires knowing which of those the compilation will produce. + */ + private static boolean namedOrUnnamed(Parameter parameter, String expectedName, boolean parameterNamesRetained) { + return !parameterNamesRetained || expectedName.equals(parameter.getName()); + } + private static boolean isMap(Parameter parameter) { ClassNode type = parameter.getType(); return type != null && (MAP_TYPE.equals(type) || type.isDerivedFrom(MAP_TYPE) || 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 index 0647e7e7e0e..47678f502a3 100644 --- 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 @@ -19,6 +19,7 @@ package org.grails.web.taglib import org.grails.gsp.GroovyPagesTemplateEngine +import org.grails.gsp.compiler.GroovyPageTypeCheckingExtension import org.grails.taglib.index.TagLibraryIndex import spock.lang.Specification @@ -57,29 +58,32 @@ class GspStaticTagResolutionSpec extends Specification { t.metaInfo.compilationException == null } - void 'a statically compiled page calling an unknown tag fails to compile'() { - given: + void 'an unknown tag is reported without failing the build by default'() { + given: 'strict checking off, as it is by default' String template = '''<%@ page compileStatic="true" %>${g.mesage(code: 'typo')}''' when: - def t = gpte.createTemplate(template, 'unknown-tag') + def t = gpte.createTemplate(template, 'unknown-tag-lenient') - 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]') + then: 'the page still compiles, so a stale index cannot break a correct build' + t.metaInfo.compilationException == null } - void 'an unknown tag in the default namespace fails to compile'() { + void 'an unknown tag fails compilation under strict checking'() { given: - String template = '''<%@ page compileStatic="true" %>${mesage(code: 'typo')}''' + System.setProperty(GroovyPageTypeCheckingExtension.STRICT_TAG_CHECKING_PROPERTY, 'true') + String template = '''<%@ page compileStatic="true" %>${g.mesage(code: 'typo')}''' when: - def t = gpte.createTemplate(template, 'unknown-default-ns-tag') + def t = gpte.createTemplate(template, 'unknown-tag-strict') - then: + 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]') + + cleanup: + System.clearProperty(GroovyPageTypeCheckingExtension.STRICT_TAG_CHECKING_PROPERTY) } void 'a namespace with no compiled tag library still resolves dynamically'() { 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 index bef354bf841..c7c35af919f 100644 --- 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 @@ -28,7 +28,7 @@ import grails.gsp.TagLib * agree on all of them. */ @TagLib -class IndexEdgeCaseTagLib { +class IndexEdgeCaseTagLib extends BaseEdgeTagLib { static namespace = 'edge' @@ -51,4 +51,15 @@ class IndexEdgeCaseTagLib { /** 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' } } From d98ea0d6849261dbf6a76dd8f0de3f8812c8ab05 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Mon, 10 Aug 2026 22:01:39 -0500 Subject: [PATCH 07/74] Leave a tag declared twice to runtime resolution When more than one tag library declares the same namespace and tag, the one registered last wins, and registration order comes from artefact scanning rather than from the classpath. TagPrecedenceSpec pins that down: the winner flips purely with registration order and carries no inherent ranking, and returnObjectForTags follows the winner rather than accumulating. The index cannot reproduce that ordering, so it no longer tries. A tag declared by two tag libraries is recorded as ambiguous and is not resolved, which leaves the choice where it is actually made. Resolving it here would risk compiling against one implementation and dispatching to another. The same tag library reaching the classpath twice, as a duplicated dependency does, names one implementation and stays resolvable. Descriptors also carry the format version they were written with, and one written by a different version is ignored rather than read under rules that may since have changed. --- .../grails/taglib/index/TagLibraryIndex.java | 64 ++++++++-- .../taglib/index/TagLibraryIndexWriter.java | 1 + .../taglib/index/TagLibraryIndexSpec.groovy | 58 ++++++++- .../web/taglib/TagPrecedenceSpec.groovy | 112 ++++++++++++++++++ 4 files changed, 227 insertions(+), 8 deletions(-) create mode 100644 grails-gsp/grails-web-taglib/src/test/groovy/org/grails/web/taglib/TagPrecedenceSpec.groovy 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 index 77eeac3de97..48591c9b243 100644 --- 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 @@ -55,14 +55,25 @@ public final class TagLibraryIndex { */ 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; + + static final String VERSION_KEY = "version"; static final String NAMESPACE_KEY = "namespace"; static final String CLASS_KEY = "class"; static final String TAGS_KEY = "tags"; private final Map> byNamespace; + private final Map> ambiguousByNamespace; - private TagLibraryIndex(Map> byNamespace) { + private TagLibraryIndex(Map> byNamespace, + Map> ambiguousByNamespace) { this.byNamespace = byNamespace; + this.ambiguousByNamespace = ambiguousByNamespace; } /** @@ -74,8 +85,9 @@ private TagLibraryIndex(Map> byNamespa public static TagLibraryIndex load(ClassLoader classLoader) { ClassLoader loader = classLoader != null ? classLoader : Thread.currentThread().getContextClassLoader(); Map> merged = new TreeMap<>(); + Map> ambiguous = new TreeMap<>(); if (loader == null) { - return new TagLibraryIndex(merged); + return new TagLibraryIndex(merged, ambiguous); } // 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 @@ -85,6 +97,9 @@ public static TagLibraryIndex load(ClassLoader classLoader) { 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, ""); @@ -95,14 +110,23 @@ public static TagLibraryIndex load(ClassLoader classLoader) { merged.computeIfAbsent(namespace, k -> new TreeMap<>()); for (String tagName : tags.split(",")) { String trimmed = tagName.trim(); - if (!trimmed.isEmpty()) { - // Later descriptors win, matching TagLibraryLookup.registerTagLib where a tag - // library registered afterwards replaces an earlier definition of the same tag. - tagsForNamespace.put(trimmed, new TagLibraryIndexEntry(namespace, trimmed, className)); + if (trimmed.isEmpty()) { + continue; } + 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)); } } - return new TagLibraryIndex(merged); + return new TagLibraryIndex(merged, ambiguous); } private static Set listDescriptors(ClassLoader loader) { @@ -156,10 +180,36 @@ public boolean hasNamespace(String 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); + } + + /** + * @param namespace a tag library namespace + * @return the tags in that namespace declared by more than one tag library + */ + public Set getAmbiguousTagNames(String namespace) { + Set ambiguousTags = ambiguousByNamespace.get(namespace); + return ambiguousTags != null ? Collections.unmodifiableSet(new TreeSet<>(ambiguousTags)) : + Collections.emptySet(); + } + /** * @return every namespace contributed by a compiled tag library */ 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 index 919df23a20c..b702aef3782 100644 --- 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 @@ -67,6 +67,7 @@ public static void write(File outputDirectory, String className, String namespac } 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 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 index b63fbf203d5..6c26ae2b9e9 100644 --- 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 @@ -107,6 +107,61 @@ class TagLibraryIndexSpec extends Specification { 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 + index.getAmbiguousTagNames('g') == ['shared'] as Set + + 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 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() + } + private Path jar(String name, Map> tagLibs) { Path path = tempDir.resolve(name) new JarOutputStream(Files.newOutputStream(path)).withCloseable { jar -> @@ -115,7 +170,8 @@ class TagLibraryIndexSpec extends Specification { jar.closeEntry() tagLibs.each { String className, List namespaceAndTags -> jar.putNextEntry(new JarEntry(TagLibraryIndex.INDEX_LOCATION + className + '.properties')) - jar.write("class=${className}\nnamespace=${namespaceAndTags[0]}\ntags=${namespaceAndTags[1]}\n".bytes) + jar.write(("version=${TagLibraryIndex.FORMAT_VERSION}\nclass=${className}\n" + + "namespace=${namespaceAndTags[0]}\ntags=${namespaceAndTags[1]}\n").bytes) jar.closeEntry() } } 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' } +} From ba48005ad8c862ee71f8a178e189c84c624ab813 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Mon, 10 Aug 2026 22:14:19 -0500 Subject: [PATCH 08/74] State the tag discovery rules once Whether a method is a tag was decided in two places: by reflection when an application registers its tag libraries, and over the syntax tree when the tag library index is written. Keeping the two in step was left to a test, and they had already drifted apart three times. The rules now live in TagDiscoveryRules, over a TagMethodView that a compiled method and a method being compiled each adapt to. The two sources differ in only two respects, both confined to their adapters: parameter defaults have already become overloads by the time a class is reflected on, and whether parameter names survive into the class file is a property of the compilation rather than of the method. TagDiscoveryRulesSpec compiles one matrix of method shapes and classifies each of them twice, from the tree and from the resulting class, asserting the two agree as well as asserting the expected answer. It covers the shapes that caused the earlier drift: a Map parameter not named attrs, an untyped parameter, @Tag and @NotATag, a framework trait name, and a defaulted trailing parameter. --- .../org/grails/taglib/TagMethodInvoker.java | 67 +------- .../taglib/discovery/AstTagMethodView.java | 127 ++++++++++++++ .../discovery/ReflectedTagMethodView.java | 110 ++++++++++++ .../taglib/discovery/TagDiscoveryRules.java | 159 ++++++++++++++++++ .../taglib/discovery/TagMethodView.java | 103 ++++++++++++ .../taglib/compiler/TagLibraryAstScanner.java | 154 +---------------- .../discovery/TagDiscoveryRulesSpec.groovy | 113 +++++++++++++ 7 files changed, 623 insertions(+), 210 deletions(-) create mode 100644 grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/discovery/AstTagMethodView.java create mode 100644 grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/discovery/ReflectedTagMethodView.java create mode 100644 grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/discovery/TagDiscoveryRules.java create mode 100644 grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/discovery/TagMethodView.java create mode 100644 grails-gsp/grails-web-taglib/src/test/groovy/org/grails/taglib/discovery/TagDiscoveryRulesSpec.groovy 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 fd1e0e33a71..7fbeee40d3c 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,8 +36,8 @@ 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 { @@ -54,27 +54,7 @@ public final class TagMethodInvoker { * * @since 8.0.0 */ - public 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 @@ -285,46 +265,7 @@ public static Object invokeTagMethod(GroovyObject tagLib, String tagName, MapTwo 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/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..82afbd5145b --- /dev/null +++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/discovery/TagDiscoveryRules.java @@ -0,0 +1,159 @@ +/* + * 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; + +/** + * 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 + */ + 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/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-web-taglib/src/main/groovy/grails/gsp/taglib/compiler/TagLibraryAstScanner.java b/grails-gsp/grails-web-taglib/src/main/groovy/grails/gsp/taglib/compiler/TagLibraryAstScanner.java index 3f5735e1cd9..63e81d1245c 100644 --- a/grails-gsp/grails-web-taglib/src/main/groovy/grails/gsp/taglib/compiler/TagLibraryAstScanner.java +++ b/grails-gsp/grails-web-taglib/src/main/groovy/grails/gsp/taglib/compiler/TagLibraryAstScanner.java @@ -20,7 +20,6 @@ import java.util.Collection; import java.util.LinkedHashSet; -import java.util.List; import java.util.Set; import groovy.lang.Closure; @@ -28,22 +27,18 @@ import org.codehaus.groovy.ast.ClassNode; import org.codehaus.groovy.ast.FieldNode; import org.codehaus.groovy.ast.MethodNode; -import org.codehaus.groovy.ast.Parameter; import org.codehaus.groovy.ast.expr.ConstantExpression; import org.codehaus.groovy.ast.expr.Expression; -import org.codehaus.groovy.ast.expr.ListExpression; -import grails.gsp.NotATag; -import grails.gsp.Tag; -import org.grails.taglib.TagMethodInvoker; +import org.grails.taglib.discovery.AstTagMethodView; +import org.grails.taglib.discovery.TagDiscoveryRules; /** * Reads a tag library's namespace and tag names from its AST. * - *

This mirrors the runtime rules in {@code TagMethodInvoker.isTagMethodCandidate} and - * {@code DefaultGrailsTagLibClass}, applied to {@link MethodNode} instead of {@code java.lang.reflect - * .Method} so that the answer is available while the tag library is being compiled. The two must agree: - * a tag recorded here but rejected at runtime would resolve statically and then fail to dispatch. + *

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 */ @@ -54,18 +49,6 @@ final class TagLibraryAstScanner { private static final String NAMESPACE_FIELD = "namespace"; 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); - - /** - * Names excluded because they are Groovy or Object plumbing rather than tags. The - * framework-trait names come from {@link TagMethodInvoker#FRAMEWORK_METHOD_NAMES} so that the - * compile-time and runtime views of "what is a tag" cannot drift apart. - */ - private static final Set NON_TAG_METHOD_NAMES = Set.of( - "invokeMethod", "methodMissing", "propertyMissing", "getProperty", "setProperty", - "getMetaClass", "setMetaClass", "equals", "hashCode", "toString"); private TagLibraryAstScanner() { } @@ -100,6 +83,7 @@ static String resolveNamespace(ClassNode classNode) { /** * @param classNode the tag library + * @param parameterNamesRetained whether this compilation writes parameter names into the class file * @return every tag the library declares, whether as a tag method or a legacy closure field */ static Collection findTagNames(ClassNode classNode, boolean parameterNamesRetained) { @@ -111,7 +95,7 @@ static Collection findTagNames(ClassNode classNode, boolean parameterNam if (method.getDeclaringClass() != null && !classNode.equals(method.getDeclaringClass())) { continue; } - if (isTagMethodCandidate(method, parameterNamesRetained)) { + if (TagDiscoveryRules.isTagMethod(new AstTagMethodView(method, parameterNamesRetained))) { tagNames.add(method.getName()); } } @@ -123,128 +107,4 @@ static Collection findTagNames(ClassNode classNode, boolean parameterNam } return tagNames; } - - private static boolean isTagMethodCandidate(MethodNode method, boolean parameterNamesRetained) { - if (!method.isPublic() || method.isStatic() || method.isAbstract() || method.isSynthetic()) { - return false; - } - // @NotATag and @Tag override the signature rule at runtime, so they must override it here too. - if (!method.getAnnotations(NOT_A_TAG_ANNOTATION).isEmpty()) { - return false; - } - if (!method.getAnnotations(TAG_ANNOTATION).isEmpty()) { - return true; - } - String name = method.getName(); - if (name.startsWith("<") || NON_TAG_METHOD_NAMES.contains(name) || - TagMethodInvoker.FRAMEWORK_METHOD_NAMES.contains(name)) { - return false; - } - // Trait application generates super-accessor bridges such as - // "grails_artefact_TagLibrarytrait$super$raw". These are synthetic at runtime but are not - // flagged as such on the AST at canonicalization, so they are excluded by name. - if (name.indexOf('$') >= 0) { - return false; - } - Parameter[] parameters = method.getParameters(); - if (name.startsWith("get") && parameters.length == 0) { - return false; - } - if (name.startsWith("is") && parameters.length == 0) { - return false; - } - if (name.startsWith("set") && parameters.length == 1) { - return false; - } - return hasConventionalTagSignature(parameters, parameterNamesRetained); - } - - /** - * A tag is invoked as {@code (Map)} or {@code (Map, Closure)}; anything else is a helper method - * that happens to live on the tag library. - * - *

Parameters with default values are taken into account. Groovy expands - * {@code formatValue(value, String path = null, Boolean tagSyntaxCall = false)} into overloads of - * one, two and three arguments, and the runtime sees those overloads through reflection. At - * canonicalization only the declaration exists, so the arities the defaults will produce are - * derived here instead; otherwise such a tag is recorded as absent and silently falls back to - * dynamic dispatch. - */ - private static boolean hasConventionalTagSignature(Parameter[] parameters, boolean parameterNamesRetained) { - int required = 0; - for (Parameter parameter : parameters) { - if (!parameter.hasInitialExpression()) { - required++; - } - } - // Every arity from the required count up to the full parameter list is callable. - for (int arity = Math.max(required, 1); arity <= parameters.length; arity++) { - if (matchesTagShape(parameters, arity, parameterNamesRetained)) { - return true; - } - } - return false; - } - - private static boolean matchesTagShape(Parameter[] parameters, int arity, boolean parameterNamesRetained) { - if (arity == 1) { - return isAttrs(parameters[0], parameterNamesRetained) || isBody(parameters[0], parameterNamesRetained); - } - if (arity == 2) { - return isAttrs(parameters[0], parameterNamesRetained) && isBody(parameters[1], parameterNamesRetained); - } - return false; - } - - /** - * Mirrors {@code TagMethodInvoker.isAttrsParameter}, which requires the declared type to be - * assignable to {@link java.util.Map}. An untyped parameter is {@code Object} and is therefore not - * an attributes parameter, so a tag declared as {@code def foo(attrs)} is not dispatchable and must - * not be recorded here either. - */ - private static boolean isAttrs(Parameter parameter, boolean parameterNamesRetained) { - return isMap(parameter) && namedOrUnnamed(parameter, "attrs", parameterNamesRetained); - } - - private static boolean isBody(Parameter parameter, boolean parameterNamesRetained) { - return isClosure(parameter) && namedOrUnnamed(parameter, "body", parameterNamesRetained); - } - - /** - * Runtime accepts a parameter as attrs or body when it carries the expected name, or when the class - * was compiled without retaining parameter names and no name is available to check. Mirroring that - * requires knowing which of those the compilation will produce. - */ - private static boolean namedOrUnnamed(Parameter parameter, String expectedName, boolean parameterNamesRetained) { - return !parameterNamesRetained || expectedName.equals(parameter.getName()); - } - - private static boolean isMap(Parameter parameter) { - ClassNode type = parameter.getType(); - return type != null && (MAP_TYPE.equals(type) || type.isDerivedFrom(MAP_TYPE) || - type.implementsInterface(MAP_TYPE)); - } - - /** - * Mirrors {@code TagMethodInvoker.isBodyParameter}, which requires assignability to - * {@link groovy.lang.Closure}. - */ - private static boolean isClosure(Parameter parameter) { - ClassNode type = parameter.getType(); - return type != null && (CLOSURE_TYPE.equals(type) || type.isDerivedFrom(CLOSURE_TYPE)); - } - - /** - * @param expression a {@code static returnObjectForTags} initialiser - * @return the listed tag names - */ - static List constantList(Expression expression) { - if (expression instanceof ListExpression list) { - return list.getExpressions().stream() - .filter(ConstantExpression.class::isInstance) - .map(e -> String.valueOf(((ConstantExpression) e).getValue())) - .toList(); - } - return List.of(); - } } 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..ab4524c0046 --- /dev/null +++ b/grails-gsp/grails-web-taglib/src/test/groovy/org/grails/taglib/discovery/TagDiscoveryRulesSpec.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.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) { }' + } + + /** + * 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)) } + } +} From 3c847aa8b5ff5de679ae1eceb15d3b759d205d8f Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Mon, 10 Aug 2026 22:38:12 -0500 Subject: [PATCH 09/74] Generate the tag library index before compilation The index was written as each tag library compiled, which left it unable to describe the source set as a whole. A renamed or deleted tag library kept its descriptor, and the manifest naming it, until the build directory was cleaned, so the index went on describing tags that no longer existed. TagLibraryIndexGenerator now writes it for a whole source directory at once, and clears what was there first, so what it describes is what exists. Sources are parsed only as far as the syntax tree, never loaded or executed, which is covered by a tag library whose static initialiser would throw if it ran. Regenerating unchanged sources produces a byte-identical index. The generateTagLibraryIndex Gradle task runs it, before page compilation and ahead of the artifact being packaged, so a project depending on this one can resolve its tags. The generator reads source rather than classes, so its classpath is the compile classpath alone: including this project's own output made it wait for the compilation it exists to precede, which showed up as a circular dependency through compileAstGroovy. Two tests hold that ordering in place. The AST transformation keeps writing descriptors, which covers tag libraries compiled outside this task. --- .../gsp/GenerateTagLibraryIndexTask.groovy | 123 +++++++++++++ .../plugin/views/gsp/GroovyPagePlugin.groovy | 36 +++- .../GenerateTagLibraryIndexTaskSpec.groovy | 120 +++++++++++++ .../discovery/TagLibraryAstDiscovery.java} | 17 +- .../index/TagLibraryIndexGenerator.java | 159 +++++++++++++++++ .../taglib/index/TagLibraryIndexWriter.java | 24 +++ .../TagLibArtefactTypeAstTransformation.java | 5 +- .../index/TagLibraryIndexGeneratorSpec.groovy | 163 ++++++++++++++++++ 8 files changed, 634 insertions(+), 13 deletions(-) create mode 100644 grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/views/gsp/GenerateTagLibraryIndexTask.groovy create mode 100644 grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/views/gsp/GenerateTagLibraryIndexTaskSpec.groovy rename grails-gsp/{grails-web-taglib/src/main/groovy/grails/gsp/taglib/compiler/TagLibraryAstScanner.java => grails-taglib/src/main/groovy/org/grails/taglib/discovery/TagLibraryAstDiscovery.java} (90%) create mode 100644 grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/index/TagLibraryIndexGenerator.java create mode 100644 grails-gsp/grails-web-taglib/src/test/groovy/org/grails/taglib/index/TagLibraryIndexGeneratorSpec.groovy 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..5a0301d14e0 --- /dev/null +++ b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/views/gsp/GenerateTagLibraryIndexTask.groovy @@ -0,0 +1,123 @@ +/* + * 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.InputDirectory +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.SkipWhenEmpty +import org.gradle.api.tasks.TaskAction +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 directory holding tag library sources, normally {@code grails-app/taglib}. + */ + @InputDirectory + @SkipWhenEmpty + @IgnoreEmptyDirectories + @PathSensitive(PathSensitivity.RELATIVE) + abstract DirectoryProperty getSourceDirectory() + + /** + * Where the index is written. Placed on the compile classpath and packaged with the artifact. + */ + @OutputDirectory + abstract DirectoryProperty getDestinationDirectory() + + /** + * 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() + + @TaskAction + void generate() { + File source = sourceDirectory.get().asFile + File destination = destinationDirectory.get().asFile + destination.mkdirs() + execOperations.javaexec { JavaExecSpec spec -> + spec.mainClass.set(GENERATOR_CLASS) + spec.classpath = generatorClasspath + spec.args( + source.canonicalPath, + destination.canonicalPath, + String.valueOf(parameterNamesRetained.getOrElse(true)), + sourceEncoding.getOrElse('UTF-8') + ) + }.assertNormalExitValue() + } +} 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..31f7c8e053b 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,6 +23,7 @@ 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.file.Directory import org.gradle.api.file.DuplicatesStrategy import org.gradle.api.file.FileCollection @@ -54,6 +55,23 @@ 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) + } + } + private void configureProject(Project project) { TaskContainer tasks = project.tasks @@ -79,6 +97,20 @@ class GroovyPagePlugin implements Plugin { JavaToolchainService toolchains = project.extensions.getByType(JavaToolchainService) Provider launcher = toolchains.launcherFor(javaExtension.toolchain) + // The index describes the tag libraries in this project and has to exist before anything that + // resolves tag calls against it is compiled. It is generated from source rather than from + // compiled classes, so its classpath is the compile classpath alone: adding this project's own + // output would make it wait for the compilation it is meant to precede. + Provider tagLibIndexDir = project.layout.buildDirectory.dir('generated/grails-taglibs') + def generateTagLibraryIndex = tasks.register('generateTagLibraryIndex', GenerateTagLibraryIndexTask) { + it.sourceDirectory.set(project.layout.projectDirectory.dir('grails-app/taglib')) + it.destinationDirectory.set(tagLibIndexDir) + it.generatorClasspath.from(project.configurations.named('compileClasspath')) + it.parameterNamesRetained.set(resolvePreserveParameterNames(project)) + } + mainSourceSet?.resources?.srcDir(tagLibIndexDir) + tasks.named('processResources').configure { it.dependsOn(generateTagLibraryIndex) } + def compileGroovyPages = tasks.register('compileGroovyPages', GroovyPageForkCompileTask) { it.destinationDirectory.set(destDir) it.tmpDirPath = getTmpDirPath(project) @@ -100,7 +132,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/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..2af513987e9 --- /dev/null +++ b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/views/gsp/GenerateTagLibraryIndexTaskSpec.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.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 + +/** + * 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() { + // A tag library has to be present for the task to have any input: it is skipped when the + // source directory is absent or empty, which is what a project with no tag libraries wants. + 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.sourceDirectory.get().asFile.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 '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 is packaged as a resource'() { + given: + SourceSet main = (project.extensions.getByType(SourceSetContainer)).getByName('main') + + expect: 'so that a project depending on this one can resolve its tags' + main.resources.srcDirs*.canonicalFile.contains( + new File(projectDir.toFile(), 'build/generated/grails-taglibs').canonicalFile) + + and: 'and resource processing waits for it to be written' + dependencyNames(project.tasks.getByName('processResources')).contains('generateTagLibraryIndex') + } + + private static Set dependencyNames(Task task) { + task.taskDependencies.getDependencies(task)*.name as Set + } + + 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-gsp/grails-web-taglib/src/main/groovy/grails/gsp/taglib/compiler/TagLibraryAstScanner.java b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/discovery/TagLibraryAstDiscovery.java similarity index 90% rename from grails-gsp/grails-web-taglib/src/main/groovy/grails/gsp/taglib/compiler/TagLibraryAstScanner.java rename to grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/discovery/TagLibraryAstDiscovery.java index 63e81d1245c..86a4c1a1668 100644 --- a/grails-gsp/grails-web-taglib/src/main/groovy/grails/gsp/taglib/compiler/TagLibraryAstScanner.java +++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/discovery/TagLibraryAstDiscovery.java @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -package grails.gsp.taglib.compiler; +package org.grails.taglib.discovery; import java.util.Collection; import java.util.LinkedHashSet; @@ -30,11 +30,8 @@ import org.codehaus.groovy.ast.expr.ConstantExpression; import org.codehaus.groovy.ast.expr.Expression; -import org.grails.taglib.discovery.AstTagMethodView; -import org.grails.taglib.discovery.TagDiscoveryRules; - /** - * Reads a tag library's namespace and tag names from its AST. + * 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 @@ -42,15 +39,15 @@ * * @since 8.0.0 */ -final class TagLibraryAstScanner { +public final class TagLibraryAstDiscovery { - static final String DEFAULT_NAMESPACE = "g"; + 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 TagLibraryAstScanner() { + private TagLibraryAstDiscovery() { } /** @@ -61,7 +58,7 @@ private TagLibraryAstScanner() { * @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 */ - static String resolveNamespace(ClassNode classNode) { + public static String resolveNamespace(ClassNode classNode) { for (ClassNode current = classNode; current != null && !ClassHelper.isObjectType(current); current = current.getSuperClass()) { FieldNode namespaceField = current.getDeclaredField(NAMESPACE_FIELD); @@ -86,7 +83,7 @@ static String resolveNamespace(ClassNode classNode) { * @param parameterNamesRetained whether this compilation writes parameter names into the class file * @return every tag the library declares, whether as a tag method or a legacy closure field */ - static Collection findTagNames(ClassNode classNode, boolean parameterNamesRetained) { + public static Collection findTagNames(ClassNode classNode, boolean parameterNamesRetained) { Set tagNames = new LinkedHashSet<>(); for (MethodNode method : classNode.getMethods()) { // TagMethodInvoker scans getDeclaredMethods(), so a method inherited from a superclass is 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..449fa10defa --- /dev/null +++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/index/TagLibraryIndexGenerator.java @@ -0,0 +1,159 @@ +/* + * 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.Collection; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Stream; + +import org.codehaus.groovy.ast.AnnotationNode; +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.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 TagLibraryIndexGenerator() { + } + + /** + * @param args source directory, output directory, and whether parameter names are retained + */ + public static void main(String[] args) throws IOException { + if (args.length < 2) { + throw new IllegalArgumentException( + "Usage: [parameterNamesRetained] [sourceEncoding]"); + } + File sourceDir = new File(args[0]); + File outputDir = new File(args[1]); + boolean parameterNamesRetained = args.length < 3 || Boolean.parseBoolean(args[2]); + String encoding = args.length > 3 && !args[3].isEmpty() ? args[3] : "UTF-8"; + generate(sourceDir, 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 { + TagLibraryIndexWriter.clear(outputDir); + if (sourceDir == null || !sourceDir.isDirectory()) { + return; + } + List sources = findGroovySources(sourceDir); + if (sources.isEmpty()) { + return; + } + + CompilerConfiguration configuration = new CompilerConfiguration(); + configuration.setParameters(parameterNamesRetained); + configuration.setSourceEncoding(encoding); + CompilationUnit unit = new CompilationUnit(configuration); + 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); + + for (ClassNode classNode : collectClassNodes(unit)) { + if (!isTagLibrary(classNode)) { + 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; + } + Collection tagNames = TagLibraryAstDiscovery.findTagNames(classNode, parameterNamesRetained); + TagLibraryIndexWriter.write(outputDir, classNode.getName(), namespace, tagNames); + } + } + + 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); + } + + 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 index b702aef3782..289dad57966 100644 --- 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 @@ -46,6 +46,30 @@ public final class TagLibraryIndexWriter { 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. * 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 3576bf76a50..a9453c9e401 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 @@ -33,6 +33,7 @@ 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.TagLibraryIndexWriter; @GroovyASTTransformation(phase = CompilePhase.CANONICALIZATION) @@ -70,7 +71,7 @@ protected void writeIndexEntry(SourceUnit sourceUnit, ClassNode classNode) { // descriptor; those callers resolve tags at runtime. return; } - String namespace = TagLibraryAstScanner.resolveNamespace(classNode); + 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. @@ -82,7 +83,7 @@ protected void writeIndexEntry(SourceUnit sourceUnit, ClassNode classNode) { boolean parameterNamesRetained = sourceUnit.getConfiguration().getParameters(); try { TagLibraryIndexWriter.write(targetDirectory, classNode.getName(), namespace, - TagLibraryAstScanner.findTagNames(classNode, parameterNamesRetained)); + TagLibraryAstDiscovery.findTagNames(classNode, parameterNamesRetained)); } catch (IOException | RuntimeException e) { GrailsASTUtils.warning(sourceUnit, classNode, "Could not write the tag library index entry for [" + classNode.getName() + "]: " + 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..707031d295b --- /dev/null +++ b/grails-gsp/grails-web-taglib/src/test/groovy/org/grails/taglib/index/TagLibraryIndexGeneratorSpec.groovy @@ -0,0 +1,163 @@ +/* + * 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 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 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() + } + + 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() + } +} From 26da0a12c57837f231b5879b180f4af118da8222 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Mon, 10 Aug 2026 22:43:19 -0500 Subject: [PATCH 10/74] Register tag libraries from the compiled index Registering a tag library asked the class what tags it declares, which walks its metaclass properties, reflects over its declared methods and scans its fields. That happens for every tag library as an application starts, and the answer was already worked out when the tag library was compiled. Registration now prefers the tags recorded in the index, and discovers them from the class only when there is no record. That keeps working unchanged for a plugin built before the index existed, for a tag library registered while an application is being developed, and for one registered by a test. A tag declared by more than one tag library is deliberately absent from the index, so a tag library holding such a tag falls back to discovery rather than registering an incomplete set. --- .../org/grails/taglib/TagLibraryLookup.java | 31 +++++- .../grails/taglib/index/TagLibraryIndex.java | 32 ++++++ .../taglib/TagLibraryLookupIndexSpec.groovy | 98 +++++++++++++++++++ 3 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 grails-gsp/grails-web-taglib/src/test/groovy/org/grails/web/taglib/TagLibraryLookupIndexSpec.groovy diff --git a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibraryLookup.java b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibraryLookup.java index 0558601c209..d705b5ad607 100644 --- a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibraryLookup.java +++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibraryLookup.java @@ -18,6 +18,7 @@ */ package org.grails.taglib; +import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; @@ -39,6 +40,7 @@ import org.grails.core.artefact.gsp.TagLibArtefactHandler; import org.grails.core.exceptions.GrailsConfigurationException; import org.grails.taglib.encoder.WithCodecHelper; +import org.grails.taglib.index.TagLibraryIndex; /** * Looks up tag library instances. @@ -54,6 +56,14 @@ public class TagLibraryLookup implements ApplicationContextAware, GrailsApplicat protected Map> tagsThatReturnObjectForNamespace = new LinkedHashMap<>(); protected Map>> encodeAsForTagNamespaces = new LinkedHashMap<>(); + /** + * The tags recorded when the tag libraries on the classpath were compiled. Registering from these + * avoids discovering tags by reflecting over, and touching the metaclass of, every tag library as + * the application starts. A tag library without a descriptor, as one from a plugin built before + * the index existed or one registered while developing, is discovered the previous way. + */ + private TagLibraryIndex tagLibraryIndex; + public void afterPropertiesSet() throws Exception { if (grailsApplication == null || applicationContext == null) { return; @@ -119,7 +129,7 @@ private void registerTagLib(GrailsTagLibClass taglib, boolean isInitialization) tagNamespaces.put(namespace, tags); } - for (String tagName : taglib.getTagNames()) { + for (String tagName : resolveTagNames(taglib)) { putTagLib(tags, tagName, taglib); tagsThatReturnObject.remove(tagName); } @@ -147,6 +157,25 @@ private void registerTagLib(GrailsTagLibClass taglib, boolean isInitialization) } } + /** + * Prefers the tags recorded when the tag library was compiled, falling back to discovering them + * from the class when it has no descriptor. + */ + private Collection resolveTagNames(GrailsTagLibClass taglib) { + if (tagLibraryIndex == null) { + tagLibraryIndex = TagLibraryIndex.load(resolveClassLoader()); + } + Set indexed = tagLibraryIndex.getTagNamesForClass(taglib.getClazz().getName()); + return !indexed.isEmpty() ? indexed : taglib.getTagNames(); + } + + private ClassLoader resolveClassLoader() { + if (grailsApplication != null && grailsApplication.getClassLoader() != null) { + return grailsApplication.getClassLoader(); + } + return getClass().getClassLoader(); + } + protected void putTagLib(Map tags, String name, GrailsTagLibClass taglib) { tags.put(name, applicationContext.getBean(taglib.getFullName())); } 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 index 48591c9b243..369fa6639e5 100644 --- 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 @@ -227,6 +227,38 @@ public Set getTagNames(String namespace) { Collections.emptySet(); } + /** + * The tags a given tag library declares, as recorded when it was compiled. + * + *

Lets a tag library be registered without discovering its tags by reflection, which otherwise + * means touching the metaclass of every tag library as an application starts. + * + * @param tagLibraryClassName the binary name of a tag library + * @return its tags, or an empty set when it has no descriptor, in which case the caller must + * discover them itself + */ + public Set getTagNamesForClass(String tagLibraryClassName) { + if (tagLibraryClassName == null) { + return Collections.emptySet(); + } + Set tagNames = new TreeSet<>(); + for (Map tags : byNamespace.values()) { + for (TagLibraryIndexEntry entry : tags.values()) { + if (tagLibraryClassName.equals(entry.tagLibraryClassName())) { + tagNames.add(entry.tagName()); + } + } + } + // A tag this class declares that another also declares is ambiguous and was not recorded + // against either, so fall back to discovery rather than register an incomplete set. + for (Set ambiguousTags : ambiguousByNamespace.values()) { + if (!ambiguousTags.isEmpty() && !tagNames.isEmpty()) { + return Collections.emptySet(); + } + } + return Collections.unmodifiableSet(tagNames); + } + /** * @return true when no compiled tag library was found, in which case callers must fall back to * runtime resolution 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..d95af353f7e --- /dev/null +++ b/grails-gsp/grails-web-taglib/src/test/groovy/org/grails/web/taglib/TagLibraryLookupIndexSpec.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.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 + } + + 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 LateTagLib { + static namespace = 'late' + def arrived(Map attrs) { 'arrived' } +} From b525a4a340f4469936e6f75f9f041a7a1740e53b Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Mon, 10 Aug 2026 22:53:32 -0500 Subject: [PATCH 11/74] Dispatch tags without writing to a metaclass Resolving a tag installed it onto the caller's metaclass so that later calls bypassed methodMissing, and every namespace dispatcher was built with its own ExpandoMetaClass carrying a method for each tag in the namespace. Tag dispatch was therefore a read of an initialised ExpandoMetaClass, guarded by a read-write lock that profiles of concurrent rendering showed to be the largest single contended cost, and every caller mutated its own metaclass the first time it used a tag. Both now dispatch through the tag library lookup, which is a map read. Removing the installed methods is not simply removing a cache: they carried overloads that adapted a CharSequence body into a closure and routed the call through the output capture protocol. Dispatching straight at the tag library skipped that and broke a tag called with a string body. The dynamic path therefore goes through methodMissingForTagLib, which already does both, with the flag that installs the metaclass methods turned off. NoMetaClassMutationSpec holds the property that resolving a tag writes to no metaclass. --- .../taglib/NamespacedTagDispatcher.groovy | 20 ++--- .../artefact/gsp/TagLibraryInvoker.groovy | 18 ++-- .../web/taglib/NoMetaClassMutationSpec.groovy | 88 +++++++++++++++++++ 3 files changed, 104 insertions(+), 22 deletions(-) create mode 100644 grails-gsp/grails-web-taglib/src/test/groovy/org/grails/web/taglib/NoMetaClassMutationSpec.groovy 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..2c5d40c2c6a 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 @@ -45,22 +45,14 @@ class NamespacedTagDispatcher extends GroovyObjectSupport { 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-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..a82d7ca2fa8 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 @@ -93,11 +93,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 +127,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/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..985a241769a --- /dev/null +++ b/grails-gsp/grails-web-taglib/src/test/groovy/org/grails/web/taglib/NoMetaClassMutationSpec.groovy @@ -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.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[]) + } + + 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' } +} From 7e1099413930cbd3cdd1a39d6f91038befc66142 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Mon, 10 Aug 2026 22:46:49 -0600 Subject: [PATCH 12/74] Report an unrecognised tag as a compilation error Now that the index is generated from source before anything resolving tag calls is compiled, it describes the tag libraries of this project as well as those of its dependencies, so a tag it cannot find in a namespace it knows is a misspelling rather than a gap in what it has seen. Those are reported as compilation errors. A namespace with no compiled tag library is still left to runtime resolution, as a tag library registered while developing or supplied by a plugin built before the index existed would be, and a tag declared by two tag libraries stays ambiguous and unresolved. Setting grails.views.gsp.strictTagChecking to false turns the error back into a warning. Generating the index no longer fails when one tag library cannot be resolved ahead of compilation. FormFieldsTagLib refers to services in its own project, which by design are not on the classpath the generator runs against, and that took the whole index down with it. Sources that fail are parsed individually and those that still fail are named and skipped, leaving them to be described by the compiler as they are built. --- .../GroovyPageTypeCheckingExtension.groovy | 18 +++--- .../index/TagLibraryIndexGenerator.java | 62 +++++++++++++++---- .../index/TagLibraryIndexGeneratorSpec.groovy | 26 ++++++++ .../taglib/GspStaticTagResolutionSpec.groovy | 24 +++---- 4 files changed, 97 insertions(+), 33 deletions(-) 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 736f78c6945..fbc9744c6e6 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 @@ -56,16 +56,20 @@ class GroovyPageTypeCheckingExtension extends GroovyTypeCheckingExtensionSupport GroovyPageTypeCheckingExtension.classLoader) /** - * Turns an unrecognised tag from a warning into a compilation error. Off by default: the index - * holds the tag libraries compiled before this page, so a stale or partial index would fail a - * build whose pages are correct. + * Set to {@code false} to report an unrecognised tag as a warning rather than failing the build. + * + *

An unrecognised tag is an error by default. The index is generated from source before + * anything that resolves tag calls is compiled, so it describes the tag libraries of this project + * as well as those of its dependencies, and a namespace it does not know about is left to runtime + * resolution rather than reported. What remains is a tag that no tag library in a namespace the + * index does know declares, which is a misspelling. */ public static final String STRICT_TAG_CHECKING_PROPERTY = 'grails.views.gsp.strictTagChecking' private static boolean isStrictTagChecking() { // Read per report rather than cached: this is only reached once a tag has already failed to // resolve, so it costs nothing on the common path and stays settable within a running compiler. - Boolean.getBoolean(STRICT_TAG_CHECKING_PROPERTY) + !'false'.equalsIgnoreCase(System.getProperty(STRICT_TAG_CHECKING_PROPERTY)) } @Override @@ -203,11 +207,7 @@ class GroovyPageTypeCheckingExtension extends GroovyTypeCheckingExtensionSupport typeCheckingVisitor.addStaticTypeError(message, call) return } - // A warning by default. The index reflects the tag libraries present when this page is - // compiled, and a tag added to an existing namespace without a rebuild of that library, or a - // library registered at runtime, would otherwise fail a build that is in fact correct. - // Set the system property to turn the warning into an error once a build regenerates the - // index reliably. + // Reporting rather than failing, for a build that has opted out of the check. SourceUnit sourceUnit = typeCheckingVisitor.sourceUnit sourceUnit?.errorCollector?.addWarning( new WarningMessage(WarningMessage.LIKELY_ERRORS, message, null, sourceUnit)) 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 index 449fa10defa..f24b880c301 100644 --- 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 @@ -96,18 +96,7 @@ public static void generate(File sourceDir, File outputDir, boolean parameterNam return; } - CompilerConfiguration configuration = new CompilerConfiguration(); - configuration.setParameters(parameterNamesRetained); - configuration.setSourceEncoding(encoding); - CompilationUnit unit = new CompilationUnit(configuration); - 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); - - for (ClassNode classNode : collectClassNodes(unit)) { + for (ClassNode classNode : parse(sources, parameterNamesRetained, encoding)) { if (!isTagLibrary(classNode)) { continue; } @@ -122,6 +111,55 @@ public static void generate(File sourceDir, File outputDir, boolean parameterNam } } + /** + * 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. A skipped tag library still has its descriptor written by the + * compiler as it is built, and until then its tags resolve dynamically, exactly as a tag library + * with no descriptor does. + */ + private static List parse(List sources, boolean parameterNamesRetained, + String encoding) { + try { + return collectClassNodes(compile(sources, parameterNamesRetained, encoding)); + } catch (Exception wholeSourceSetFailed) { + List classNodes = new ArrayList<>(); + List skipped = new ArrayList<>(); + for (File source : sources) { + try { + classNodes.addAll(collectClassNodes( + compile(List.of(source), parameterNamesRetained, encoding))); + } catch (Exception singleSourceFailed) { + skipped.add(source.getName()); + } + } + if (!skipped.isEmpty()) { + System.out.println("Tag library index: could not read " + String.join(", ", skipped) + + " before compilation; their tags resolve dynamically until they are compiled."); + } + classNodes.sort(Comparator.comparing(ClassNode::getName)); + return classNodes; + } + } + + private static CompilationUnit compile(List sources, boolean parameterNamesRetained, + String encoding) { + CompilerConfiguration configuration = new CompilerConfiguration(); + configuration.setParameters(parameterNamesRetained); + configuration.setSourceEncoding(encoding); + CompilationUnit unit = new CompilationUnit(configuration); + 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; + } + private static List collectClassNodes(CompilationUnit unit) { List classNodes = new ArrayList<>(); unit.getAST().getModules().forEach(module -> classNodes.addAll(module.getClasses())); 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 index 707031d295b..ece517271ae 100644 --- 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 @@ -111,6 +111,32 @@ class TagLibraryIndexGeneratorSpec extends Specification { 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, to be described 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) { } }') 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 index 47678f502a3..cc0c77909c5 100644 --- 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 @@ -58,29 +58,29 @@ class GspStaticTagResolutionSpec extends Specification { t.metaInfo.compilationException == null } - void 'an unknown tag is reported without failing the build by default'() { - given: 'strict checking off, as it is by default' + void 'an unknown tag fails compilation'() { + given: 'a misspelled tag in a namespace the index knows' String template = '''<%@ page compileStatic="true" %>${g.mesage(code: 'typo')}''' when: - def t = gpte.createTemplate(template, 'unknown-tag-lenient') + def t = gpte.createTemplate(template, 'unknown-tag-strict') - then: 'the page still compiles, so a stale index cannot break a correct build' - t.metaInfo.compilationException == null + then: 'it 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 unknown tag fails compilation under strict checking'() { + void 'the check can be turned down to a warning'() { given: - System.setProperty(GroovyPageTypeCheckingExtension.STRICT_TAG_CHECKING_PROPERTY, 'true') + System.setProperty(GroovyPageTypeCheckingExtension.STRICT_TAG_CHECKING_PROPERTY, 'false') String template = '''<%@ page compileStatic="true" %>${g.mesage(code: 'typo')}''' when: - def t = gpte.createTemplate(template, 'unknown-tag-strict') + def t = gpte.createTemplate(template, 'unknown-tag-lenient') - 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]') + then: 'the page compiles, for a build that would rather not fail on this' + t.metaInfo.compilationException == null cleanup: System.clearProperty(GroovyPageTypeCheckingExtension.STRICT_TAG_CHECKING_PROPERTY) From 02a8e799255bd8d9be559b0547a3268898d97cfb Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Mon, 10 Aug 2026 22:54:45 -0600 Subject: [PATCH 13/74] Add an explicit entry point for invoking a resolved tag Calling a tag reaches the tag library through invokeMethod, which leaves a dynamic call site in the caller's bytecode even when that caller is statically compiled. Once a tag has been resolved against the index there is nothing left to decide beyond which bean holds it, so the call can be an ordinary method call. CompiledTagInvocation is that call. It takes the namespace and name as arguments and ends at TagOutput.captureTagOutput, which is where the dynamic path ends too, so attribute and body handling, output capture, encoding and return-object behaviour are the same either way. TagLibNamespaceMethodDispatcher, which is how a statically compiled page reaches a tag, now goes through it. This is the target a rewritten call site needs. Rewriting the call sites themselves is not part of this commit. --- .../grails/taglib/CompiledTagInvocation.java | 84 +++++++++++++++++++ .../TagLibNamespaceMethodDispatcher.groovy | 2 +- .../taglib/CompiledTagInvocationSpec.groovy | 72 ++++++++++++++++ 3 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/CompiledTagInvocation.java create mode 100644 grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/CompiledTagInvocationSpec.groovy 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..3cdd8fce336 --- /dev/null +++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/CompiledTagInvocation.java @@ -0,0 +1,84 @@ +/* + * 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.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 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, or {@code null} when the tag was called without one + * @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, Closure 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, or {@code null} when the tag was called without one + * @param outputContext where the tag writes + * @return whatever the tag produces + */ + public static Object invoke(TagLibraryLookup lookup, String namespace, String tagName, + Map attrs, Closure 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(); + return TagOutput.captureTagOutput(lookup, namespace, tagName, attributes, body, outputContext); + } +} 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..1176d1c89a5 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, (Closure) body, outputContext) } } 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..82c7fde544d --- /dev/null +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/CompiledTagInvocationSpec.groovy @@ -0,0 +1,72 @@ +/* + * 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') + } +} From b34de798bbac3c06de1a54000c30dcd250abfbf3 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Mon, 10 Aug 2026 23:02:40 -0600 Subject: [PATCH 14/74] Stop installing tags onto tag library metaclasses Every tag library had every tag in every namespace installed onto its metaclass as it was constructed, and again for the whole application at plugin bootstrap, so that a tag library calling another tag found a method rather than falling through to methodMissing. A namespace resolved through propertyMissing was installed as a property too. None of that is needed now that tags are resolved through the tag library lookup and invoked through CompiledTagInvocation, so it is gone. Registering a tag library with the lookup is all bootstrap does. TagLibraryMetaUtils is deprecated. What remains of it is the dynamic dispatch a tag library registered at runtime still relies on, reached with metaclass installation switched off. The compile-time warning for a closure-based tag now says what the consequence is, that calls to it stay dynamic because it cannot be resolved when a page is compiled, and shows the method form to use instead. --- .../grails/taglib/TagLibraryMetaUtils.groovy | 12 ++++++++++ .../groovy/grails/artefact/TagLibrary.groovy | 22 +++++-------------- .../TagLibArtefactTypeAstTransformation.java | 6 +++-- .../web/GroovyPagesGrailsPlugin.groovy | 3 ++- 4 files changed, 24 insertions(+), 19 deletions(-) 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..4726dd66692 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,18 @@ 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. + * + * @deprecated Installing tags onto metaclasses is no longer part of dispatching a tag. Resolve + * through {@link TagLibraryLookup} and invoke through {@link CompiledTagInvocation}. + */ +@Deprecated class TagLibraryMetaUtils { private static final Log LOG = LogFactory.getLog(TagLibraryMetaUtils) diff --git a/grails-gsp/grails-web-taglib/src/main/groovy/grails/artefact/TagLibrary.groovy b/grails-gsp/grails-web-taglib/src/main/groovy/grails/artefact/TagLibrary.groovy index e4989aceca5..502ac90940e 100644 --- a/grails-gsp/grails-web-taglib/src/main/groovy/grails/artefact/TagLibrary.groovy +++ b/grails-gsp/grails-web-taglib/src/main/groovy/grails/artefact/TagLibrary.groovy @@ -26,8 +26,6 @@ import jakarta.annotation.PostConstruct import org.springframework.web.context.request.RequestAttributes import grails.artefact.gsp.TagLibraryInvoker -import grails.util.Environment -import grails.util.GrailsMetaClassUtils import grails.web.api.ServletAttributes import grails.web.api.WebAttributes import org.grails.buffer.GrailsPrintWriter @@ -35,7 +33,6 @@ import org.grails.encoder.Encoder import org.grails.taglib.GrailsTagException import org.grails.taglib.GroovyPageAttributes 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 @@ -59,11 +56,14 @@ trait TagLibrary implements WebAttributes, ServletAttributes, TagLibraryInvoker private Encoder rawEncoder + /** + * 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 nothing is + * installed and no metaclass is initialised on the way to a tag. + */ @PostConstruct void initializeTagLibrary() { - if (!Environment.isDevelopmentMode()) { - TagLibraryMetaUtils.enhanceTagLibMetaClass(GrailsMetaClassUtils.getExpandoMetaClass(getClass()), getTagLibraryLookup(), getTaglibNamespace()) - } } Object raw(Object value) { @@ -175,16 +175,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/gsp/taglib/compiler/TagLibArtefactTypeAstTransformation.java b/grails-gsp/grails-web-taglib/src/main/groovy/grails/gsp/taglib/compiler/TagLibArtefactTypeAstTransformation.java index a9453c9e401..6195475be4a 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 @@ -108,8 +108,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/plugin/src/main/groovy/org/grails/plugins/web/GroovyPagesGrailsPlugin.groovy b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/GroovyPagesGrailsPlugin.groovy index 3d1b0ae61b6..98b4eb3de3b 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 @@ -306,8 +306,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 From 9412856f12ba66b26a0d1f18e95b3e1265983363 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Mon, 10 Aug 2026 23:21:25 -0600 Subject: [PATCH 15/74] Compile a call to a known tag into a direct invocation Writing g.link(controller: 'book') reaches the tag library through propertyMissing to find the namespace and invokeMethod to find the tag, which leaves a dynamic call site in the bytecode of a tag library even when it is statically compiled. Both names are fixed in the source and the index says whether that tag exists, so the call is replaced with a call to CompiledTagInvocation. Only calls whose shape is evident from the source are rewritten: a tag takes attributes, a body, both or neither, written as literals. A call whose attributes are assembled at runtime, a namespace no compiled tag library declares, a tag declared by more than one of them, and a namespace shadowed by a field of the same name are all left to resolve as they did before. CompiledTagCallRewriterSpec renders through each of those shapes, since a rewrite that changed behaviour is the failure that matters. Behaviour alone cannot show that anything was rewritten, because the dynamic route produces the same output, so CompiledTagCallBytecodeSpec compiles a tag library and looks for the invocation in the class file, and for its absence where nothing should have been rewritten. --- .../compiler/CompiledTagCallRewriter.java | 187 ++++++++++++++++++ .../TagLibArtefactTypeAstTransformation.java | 14 ++ .../taglib/CompiledTagCallBytecodeSpec.groovy | 97 +++++++++ .../taglib/CompiledTagCallRewriterSpec.groovy | 53 +++++ .../web/taglib/RewrittenCallsTagLib.groovy | 52 +++++ 5 files changed, 403 insertions(+) create mode 100644 grails-gsp/grails-web-taglib/src/main/groovy/grails/gsp/taglib/compiler/CompiledTagCallRewriter.java create mode 100644 grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/CompiledTagCallBytecodeSpec.groovy create mode 100644 grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/CompiledTagCallRewriterSpec.groovy create mode 100644 grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/RewrittenCallsTagLib.groovy 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..d6e91f7d56d --- /dev/null +++ b/grails-gsp/grails-web-taglib/src/main/groovy/grails/gsp/taglib/compiler/CompiledTagCallRewriter.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 grails.gsp.taglib.compiler; + +import java.util.List; + +import org.codehaus.groovy.ast.ClassCodeExpressionTransformer; +import org.codehaus.groovy.ast.ClassHelper; +import org.codehaus.groovy.ast.ClassNode; +import org.codehaus.groovy.ast.MethodNode; +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.control.SourceUnit; + +import org.grails.taglib.CompiledTagInvocation; +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 + * {@link CompiledTagInvocation#invoke}, an ordinary static method call. + * + *

Only calls whose shape is evident from the source are rewritten: a tag takes attributes, a body, + * both or neither, and where the arguments cannot be recognised as that the call is left alone and + * resolves as it did before. A namespace the index does not know, or a tag it does not hold, is also + * left alone, which is what keeps a tag library registered at runtime working. + * + * @since 8.0.0 + */ +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 INVOKE = "invoke"; + + private final SourceUnit sourceUnit; + private final TagLibraryIndex index; + private final ClassNode classNode; + private int rewritten; + + CompiledTagCallRewriter(SourceUnit sourceUnit, TagLibraryIndex index, ClassNode classNode) { + this.sourceUnit = sourceUnit; + this.index = index; + this.classNode = classNode; + } + + /** + * @return how many calls were rewritten, for tests to assert against + */ + int getRewrittenCount() { + return rewritten; + } + + void rewrite() { + for (MethodNode method : classNode.getMethods()) { + if (method.getCode() != null && !method.isAbstract()) { + visitClassCodeContainer(method.getCode()); + } + } + } + + @Override + protected SourceUnit getSourceUnit() { + return sourceUnit; + } + + @Override + public Expression transform(Expression expression) { + if (expression instanceof MethodCallExpression call) { + Expression rewrite = rewriteTagCall(call); + if (rewrite != null) { + rewritten++; + return rewrite; + } + } + return super.transform(expression); + } + + private Expression rewriteTagCall(MethodCallExpression call) { + String namespace = namespaceOf(call.getObjectExpression()); + if (namespace == null || !index.hasNamespace(namespace)) { + return null; + } + if (!(call.getMethod() instanceof ConstantExpression methodName) || + methodName.getValue() == null) { + return null; + } + String tagName = methodName.getValue().toString(); + if (index.lookup(namespace, tagName) == null) { + // Unknown here, or declared by more than one tag library and so deliberately unresolved. + return null; + } + // A field or property of the same name as the namespace is that member, not a tag library. + if (classNode.getDeclaredField(namespace) != null) { + return null; + } + + Expression[] attrsAndBody = attributesAndBody(call.getArguments()); + if (attrsAndBody == null) { + return null; + } + ArgumentListExpression invocationArgs = new ArgumentListExpression(); + invocationArgs.addExpression(new MethodCallExpression(VariableExpression.THIS_EXPRESSION, + LOOKUP_ACCESSOR, MethodCallExpression.NO_ARGUMENTS)); + invocationArgs.addExpression(new ConstantExpression(namespace)); + invocationArgs.addExpression(new ConstantExpression(tagName)); + invocationArgs.addExpression(attrsAndBody[0]); + invocationArgs.addExpression(attrsAndBody[1]); + return new StaticMethodCallExpression(INVOCATION_TYPE, INVOKE, invocationArgs); + } + + /** + * @return the attributes and body to pass, or {@code null} when the arguments are not recognisably + * a tag call and it should be left to resolve as before + */ + private Expression[] attributesAndBody(Expression arguments) { + if (!(arguments instanceof TupleExpression tuple)) { + return null; + } + List args = tuple.getExpressions(); + Expression noAttributes = new MapExpression(); + Expression noBody = 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; + } + } + + /** + * @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; + } +} 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 6195475be4a..2ea4ace5c9e 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 @@ -34,6 +34,7 @@ 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) @@ -52,6 +53,7 @@ public class TagLibArtefactTypeAstTransformation extends ArtefactTypeAstTransfor protected String resolveArtefactType(SourceUnit sourceUnit, AnnotationNode annotationNode, ClassNode classNode) { addClosureTagDeprecationWarnings(sourceUnit, classNode); writeIndexEntry(sourceUnit, classNode); + rewriteResolvedTagCalls(sourceUnit, classNode); return "TagLibrary"; } @@ -91,6 +93,18 @@ protected void writeIndexEntry(SourceUnit sourceUnit, ClassNode classNode) { } } + /** + * 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.load(sourceUnit.getClassLoader()); + if (index.isEmpty()) { + return; + } + new CompiledTagCallRewriter(sourceUnit, index, classNode).rewrite(); + } + @Override protected ClassNode getAnnotationType() { return MY_TYPE; 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..0c220ede2ad --- /dev/null +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/CompiledTagCallBytecodeSpec.groovy @@ -0,0 +1,97 @@ +/* + * 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 { + + @TempDir + Path tempDir + + void 'a call to a known tag is compiled as an invocation, not a dynamic call'() { + when: + byte[] compiled = compile(''' + import grails.gsp.TagLib + @TagLib + class BytecodeCheckTagLib { + static namespace = 'bytecheck' + def calls(Map attrs) { + out << g.link(controller: 'book') + } + } + ''', 'BytecodeCheckTagLib') + + then: 'the invocation entry point is referenced' + references(compiled, 'org/grails/taglib/CompiledTagInvocation') + } + + void 'a call into a namespace no compiled tag library declares is left dynamic'() { + when: + byte[] 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, 'org/grails/taglib/CompiledTagInvocation') + } + + 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', 'link') != null + } + + 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')) + + 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/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/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) + } +} From d6a204f673cc2efd9d7ad71a42dcdd057e4e788e Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Mon, 10 Aug 2026 23:48:23 -0600 Subject: [PATCH 16/74] Correct three compatibility faults in tag resolution A review of the stack found the strict check and the explicit invocation path each breaking cases the dynamic path handled. An unrecognised tag is a warning again rather than an error. Knowing that a namespace holds some compiled tag libraries is not knowing that it holds all of them: a plugin built before the index existed contributes tags to g without a descriptor, a tag library registered while an application runs contributes more, and the index generator skips a source it cannot resolve ahead of compilation. In each case the namespace is known but incomplete, so a tag missing from it is not necessarily a misspelling. Failing the build needs a namespace able to state that it is complete, which the descriptors cannot yet do. grails.views.gsp.strictTagChecking opts in to the error. A tag declared by more than one tag library was reported as no such tag. The index deliberately leaves it unresolved so that runtime precedence decides, which the checker read as absent. It now asks whether the tag is ambiguous before reporting it. A tag body given as text threw a GroovyCastException. The dynamic path accepted text through overloads that wrapped it in a closure, and the explicit API narrowed the body to Closure, which a namespaced dispatcher call with a string body could not satisfy. The API takes the body as it is given and wraps text, as before. Registering a tag library after startup, as reloading a changed class during development and registering one from a test both do, uses the descriptor supplied rather than the one recorded when the class was compiled, which no longer describes what is being registered. --- .../GroovyPageTypeCheckingExtension.groovy | 21 ++++++++---- .../grails/taglib/CompiledTagInvocation.java | 16 +++++---- .../TagLibNamespaceMethodDispatcher.groovy | 2 +- .../org/grails/taglib/TagLibraryLookup.java | 11 +++++-- .../taglib/TagLibraryLookupIndexSpec.groovy | 20 +++++++++++ .../taglib/GspStaticTagResolutionSpec.groovy | 33 ++++++++++++++----- 6 files changed, 77 insertions(+), 26 deletions(-) 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 fbc9744c6e6..1fd8932a966 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 @@ -56,20 +56,22 @@ class GroovyPageTypeCheckingExtension extends GroovyTypeCheckingExtensionSupport GroovyPageTypeCheckingExtension.classLoader) /** - * Set to {@code false} to report an unrecognised tag as a warning rather than failing the build. + * Set to {@code true} to fail compilation on a tag no compiled tag library declares. * - *

An unrecognised tag is an error by default. The index is generated from source before - * anything that resolves tag calls is compiled, so it describes the tag libraries of this project - * as well as those of its dependencies, and a namespace it does not know about is left to runtime - * resolution rather than reported. What remains is a tag that no tag library in a namespace the - * index does know declares, which is a misspelling. + *

Off by default, because knowing that a namespace holds some compiled tag libraries is not the + * same as knowing it holds all of them. A plugin built before the index existed contributes tags + * to {@code g} without a descriptor, a tag library registered while an application runs + * contributes more, and the index generator skips a source it cannot resolve ahead of compilation. + * In each case the namespace is known but incomplete, and a tag missing from it is not necessarily + * a misspelling. Until a namespace can state that it is complete, an unrecognised tag is reported + * as a warning. */ public static final String STRICT_TAG_CHECKING_PROPERTY = 'grails.views.gsp.strictTagChecking' private static boolean isStrictTagChecking() { // Read per report rather than cached: this is only reached once a tag has already failed to // resolve, so it costs nothing on the common path and stays settable within a running compiler. - !'false'.equalsIgnoreCase(System.getProperty(STRICT_TAG_CHECKING_PROPERTY)) + Boolean.getBoolean(STRICT_TAG_CHECKING_PROPERTY) } @Override @@ -201,6 +203,11 @@ class GroovyPageTypeCheckingExtension extends GroovyTypeCheckingExtensionSupport if (TAG_LIBRARY_INDEX.lookup(namespace, tagName) != null) { return } + if (TAG_LIBRARY_INDEX.isAmbiguous(namespace, tagName)) { + // Declared by more than one tag library, so which one runs is decided by registration + // order at runtime. The tag exists; it just cannot be bound here. + return + } String message = "No such tag [${tagName}] in namespace [${namespace}]. Known tags: " + TAG_LIBRARY_INDEX.getTagNames(namespace).join(', ') if (isStrictTagChecking()) { 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 index 3cdd8fce336..c05cb6ae748 100644 --- 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 @@ -21,8 +21,6 @@ import java.util.Collections; import java.util.Map; -import groovy.lang.Closure; - import org.grails.taglib.encoder.OutputContext; import org.grails.taglib.encoder.OutputContextLookupHelper; @@ -53,11 +51,11 @@ private CompiledTagInvocation() { * @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, or {@code null} when the tag was called without one + * @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, Closure body) { + Map attrs, Object body) { return invoke(lookup, namespace, tagName, attrs, body, OutputContextLookupHelper.lookupOutputContext()); } @@ -69,16 +67,20 @@ public static Object invoke(TagLibraryLookup lookup, String namespace, String ta * @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, or {@code null} when the tag was called without one + * @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, Closure body, OutputContext outputContext) { + 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(); - return TagOutput.captureTagOutput(lookup, namespace, tagName, attributes, body, outputContext); + // 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); } } 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 1176d1c89a5..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) { - CompiledTagInvocation.invoke(lookup, namespace, name, attrs, (Closure) body, outputContext) + CompiledTagInvocation.invoke(lookup, namespace, name, attrs, body, outputContext) } } diff --git a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibraryLookup.java b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibraryLookup.java index d705b5ad607..a1976fede93 100644 --- a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibraryLookup.java +++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibraryLookup.java @@ -129,7 +129,7 @@ private void registerTagLib(GrailsTagLibClass taglib, boolean isInitialization) tagNamespaces.put(namespace, tags); } - for (String tagName : resolveTagNames(taglib)) { + for (String tagName : resolveTagNames(taglib, isInitialization)) { putTagLib(tags, tagName, taglib); tagsThatReturnObject.remove(tagName); } @@ -161,7 +161,14 @@ private void registerTagLib(GrailsTagLibClass taglib, boolean isInitialization) * Prefers the tags recorded when the tag library was compiled, falling back to discovering them * from the class when it has no descriptor. */ - private Collection resolveTagNames(GrailsTagLibClass taglib) { + private Collection resolveTagNames(GrailsTagLibClass taglib, boolean isInitialization) { + if (!isInitialization) { + // Registering after startup means the tag library has been supplied directly, as reloading + // a changed class during development and registering one from a test both do. The + // descriptor describes the class as it was compiled, which is no longer what is being + // registered, so the class itself is asked. + return taglib.getTagNames(); + } if (tagLibraryIndex == null) { tagLibraryIndex = TagLibraryIndex.load(resolveClassLoader()); } 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 index d95af353f7e..eeb7a328fbd 100644 --- 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 @@ -70,6 +70,20 @@ class TagLibraryLookupIndexSpec extends Specification { 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 @@ -91,6 +105,12 @@ class FallbackTagLib { 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' 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 index cc0c77909c5..888677b13f0 100644 --- 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 @@ -58,28 +58,43 @@ class GspStaticTagResolutionSpec extends Specification { t.metaInfo.compilationException == null } - void 'an unknown tag fails compilation'() { - given: 'a misspelled tag in a namespace the index knows' + 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 is reported as a warning and the page still compiles' + t.metaInfo.compilationException == null + } + + void 'an unrecognised tag fails compilation under strict checking'() { + given: + System.setProperty(GroovyPageTypeCheckingExtension.STRICT_TAG_CHECKING_PROPERTY, 'true') String template = '''<%@ page compileStatic="true" %>${g.mesage(code: 'typo')}''' when: def t = gpte.createTemplate(template, 'unknown-tag-strict') - then: 'it is reported when the page is compiled rather than when it renders' + 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]') + + cleanup: + System.clearProperty(GroovyPageTypeCheckingExtension.STRICT_TAG_CHECKING_PROPERTY) } - void 'the check can be turned down to a warning'() { - given: - System.setProperty(GroovyPageTypeCheckingExtension.STRICT_TAG_CHECKING_PROPERTY, 'false') - String template = '''<%@ page compileStatic="true" %>${g.mesage(code: 'typo')}''' + 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' + System.setProperty(GroovyPageTypeCheckingExtension.STRICT_TAG_CHECKING_PROPERTY, 'true') + String template = '''<%@ page compileStatic="true" %>${g.link(controller: 'book')}''' when: - def t = gpte.createTemplate(template, 'unknown-tag-lenient') + def t = gpte.createTemplate(template, 'ambiguous-not-unknown') - then: 'the page compiles, for a build that would rather not fail on this' + then: 'a resolvable tag still compiles under strict checking' t.metaInfo.compilationException == null cleanup: From 9d2304f53f8018752ef47d97ad5437e6c9e292f8 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Mon, 10 Aug 2026 23:54:16 -0600 Subject: [PATCH 17/74] Compile tag calls in controllers too A tag library rewrote its own tag calls as it compiled, but a controller can call tags as well. It gains that from the tag library invoker trait rather than from being a tag library, so nothing rewrote its calls and they stayed dynamic. A global transformation now rewrites tag calls in any class carrying that trait, which covers controllers without naming them and without a second copy of the rules. It runs after trait injection, since whether a class can call tags is only settled once its traits are applied, and it does nothing at all when no compiled tag library is on the classpath. ControllerTagCallRewriteSpec compiles a class with the trait and one without, and looks in the class files for the invocation, since a rewritten call and a dynamic one produce the same output. --- .../compiler/CompiledTagCallRewriter.java | 8 +- grails-gsp/plugin/build.gradle | 1 + .../CompiledTagCallTransformation.groovy | 82 +++++++++++++++++ ...odehaus.groovy.transform.ASTTransformation | 1 + .../ControllerTagCallRewriteSpec.groovy | 88 +++++++++++++++++++ 5 files changed, 176 insertions(+), 4 deletions(-) create mode 100644 grails-gsp/plugin/src/ast/groovy/grails/compiler/traits/CompiledTagCallTransformation.groovy create mode 100644 grails-gsp/plugin/src/ast/resources/META-INF/services/org.codehaus.groovy.transform.ASTTransformation create mode 100644 grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/ControllerTagCallRewriteSpec.groovy 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 index d6e91f7d56d..cfdc7932155 100644 --- 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 @@ -55,7 +55,7 @@ * * @since 8.0.0 */ -class CompiledTagCallRewriter extends ClassCodeExpressionTransformer { +public class CompiledTagCallRewriter extends ClassCodeExpressionTransformer { private static final ClassNode INVOCATION_TYPE = ClassHelper.make(CompiledTagInvocation.class); private static final String LOOKUP_ACCESSOR = "getTagLibraryLookup"; @@ -66,7 +66,7 @@ class CompiledTagCallRewriter extends ClassCodeExpressionTransformer { private final ClassNode classNode; private int rewritten; - CompiledTagCallRewriter(SourceUnit sourceUnit, TagLibraryIndex index, ClassNode classNode) { + public CompiledTagCallRewriter(SourceUnit sourceUnit, TagLibraryIndex index, ClassNode classNode) { this.sourceUnit = sourceUnit; this.index = index; this.classNode = classNode; @@ -75,11 +75,11 @@ class CompiledTagCallRewriter extends ClassCodeExpressionTransformer { /** * @return how many calls were rewritten, for tests to assert against */ - int getRewrittenCount() { + public int getRewrittenCount() { return rewritten; } - void rewrite() { + public void rewrite() { for (MethodNode method : classNode.getMethods()) { if (method.getCode() != null && !method.isAbstract()) { visitClassCodeContainer(method.getCode()); 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..94718279465 --- /dev/null +++ b/grails-gsp/plugin/src/ast/groovy/grails/compiler/traits/CompiledTagCallTransformation.groovy @@ -0,0 +1,82 @@ +/* + * 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 grails.artefact.gsp.TagLibraryInvoker +import grails.gsp.taglib.compiler.CompiledTagCallRewriter +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. + * + *

Runs after trait injection, since whether a class can call tags is only settled once its traits + * have been applied. + * + * @since 8.0.0 + */ +@CompileStatic +@GroovyASTTransformation(phase = CompilePhase.CANONICALIZATION) +class CompiledTagCallTransformation implements ASTTransformation { + + private static final ClassNode TAG_LIBRARY_INVOKER = ClassHelper.make(TagLibraryInvoker) + + @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.load(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 + */ + private static boolean callsTags(ClassNode classNode) { + classNode.implementsInterface(TAG_LIBRARY_INVOKER) || classNode.declaresInterface(TAG_LIBRARY_INVOKER) + } +} 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/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..10679128461 --- /dev/null +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/ControllerTagCallRewriteSpec.groovy @@ -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.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. + */ +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.link(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.link(controller: 'book') + } + } + ''', 'PlainService') + + then: + !references(compiled, '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')) + } +} From 164f297c58ba630d54081e8daf8ce65220e99221 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 11 Aug 2026 00:30:40 -0600 Subject: [PATCH 18/74] Document compiled tag resolution Describes how tag libraries are described when compiled and how that resolves tag calls, what is compiled into a direct invocation and what stays dynamic, how an unrecognised tag is reported and how to turn that into an error, why a closure-based tag cannot be resolved, and where the description is written and packaged. Adds the corresponding what's new entry and an upgrade note covering the two things an existing application notices: the warning for an unrecognised tag, and the warning for a closure-based tag with the method form to replace it. --- .../src/en/guide/introduction/whatsNew.adoc | 27 +++++ .../theWebLayer/gsp/taglibs/compiledTags.adoc | 98 +++++++++++++++++++ grails-doc/src/en/guide/toc.yml | 1 + .../src/en/guide/upgrading/upgrading80x.adoc | 33 +++++++ 4 files changed, 159 insertions(+) create mode 100644 grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc diff --git a/grails-doc/src/en/guide/introduction/whatsNew.adoc b/grails-doc/src/en/guide/introduction/whatsNew.adoc index aa8b28f1b17..b81caeaa7c4 100644 --- a/grails-doc/src/en/guide/introduction/whatsNew.adoc +++ b/grails-doc/src/en/guide/introduction/whatsNew.adoc @@ -277,3 +277,30 @@ be enabled (`grails.mongodb.transactional = true`). Spring Data repositories are session are shared, and the two object-mapping models stay separate. See the link:{mongodb5Guide}index.html#springDataInterop[Spring Data MongoDB Interoperability] section of the GORM for MongoDB guide for 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. 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 or dispatcher metaclasses to make dispatch work: + +[source,groovy] +---- +class BookController { + def index() { + String markup = g.link(controller: 'book') // compiled into a direct invocation + } +} +---- + +A tag that no compiled tag library declares is reported as a compilation warning, which +`-Dgrails.views.gsp.strictTagChecking=true` turns into an error. Calls that cannot be resolved when +compiled — attributes assembled at runtime, a namespace no compiled tag library declares, or a tag +declared by more than one of them — are dispatched exactly as before. + +Defining a tag as a `Closure` field remains supported but is deprecated and now warns at compile time: +a closure has no signature to resolve against, so calls to such a tag stay dynamic. 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..0155c450ce1 --- /dev/null +++ b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc @@ -0,0 +1,98 @@ +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 closure has no signature to resolve, so a call to a tag defined this way cannot be resolved when the +calling code is compiled and is dispatched dynamically instead. Compiling a tag library that declares +one produces a warning naming the tag. + +==== Calling tags + +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.link(controller: 'book') // compiled into a direct invocation + String other = greet.hello(name: 'Grails') // likewise + } +} +---- + +This applies where the call is written with literal attributes, a literal body, both or neither. A +call whose attributes are assembled at runtime is dispatched as before: + +[source,groovy] +---- +Map attrs = buildAttributes() +g.link(attrs) // dispatched dynamically +---- + +So is a call into a namespace no compiled tag library declares, which is what allows a tag library +registered while an application is running to keep working. + +==== Reporting unknown tags + +By default, a tag that no compiled tag library declares is reported as a compilation warning. It is a +warning rather than an error because a namespace can hold tag libraries that were not compiled with a +description: a plugin built against an earlier version of Grails contributes tags without one, and a +tag library registered at runtime contributes more. A tag missing from the description is therefore +not necessarily a misspelling. + +Set the following system property when building to turn that warning into a compilation error: + +[source,bash] +---- +-Dgrails.views.gsp.strictTagChecking=true +---- + +A tag declared by more than one tag library is never reported. Which one runs depends on the order the +tag libraries are registered, which is not known when the calling code is compiled, so such a call is +left to be resolved at runtime. + +==== 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. + +For an application's own tag libraries, the `generateTagLibraryIndex` task writes the description from +the sources under `grails-app/taglib` before compilation, so tags an application declares are +resolvable in the same compilation that defines them. Tag libraries elsewhere on the source path are +described as they are compiled, which makes them resolvable to anything compiled afterwards. diff --git a/grails-doc/src/en/guide/toc.yml b/grails-doc/src/en/guide/toc.yml index b0e4a5c3ff4..3026f5ab81e 100644 --- a/grails-doc/src/en/guide/toc.yml +++ b/grails-doc/src/en/guide/toc.yml @@ -154,6 +154,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 2d777e80ccb..704e7f272ed 100644 --- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc +++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc @@ -2255,3 +2255,36 @@ resolved unambiguously before can become ambiguous. Removing the packages from `grails.spring.bean.packages`, or the stray classes from those packages, restores the previous set of beans. + +=== 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. Existing applications need no change: a tag that +cannot be resolved when compiled is dispatched exactly as before, which covers tag libraries from +plugins built against earlier versions of Grails, tag libraries registered while an application runs, +and calls whose attributes are assembled at runtime. + +Two things are worth knowing when upgrading. + +A tag that no compiled tag library declares produces a compilation warning. Building with +`-Dgrails.views.gsp.strictTagChecking=true` turns those warnings into errors, 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. + +Tags defined as `Closure` fields now warn at compile time. They still work, but a closure carries no +signature, so calls to such a tag cannot be resolved when the calling code is compiled and stay +dynamic. Convert them to methods: + +[source,groovy] +---- +// Before +Closure hello = { Map attrs -> + out << "Hello ${attrs.name}" +} + +// After +def hello(Map attrs) { + out << "Hello ${attrs.name}" +} +---- + From 2ab866640aead2283f2829f923293d8915821bdb Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 11 Aug 2026 00:30:40 -0600 Subject: [PATCH 19/74] Let more than one directory hold tag libraries The pre-compilation task scanned only grails-app/taglib, so a project keeping tag libraries elsewhere had them described as they compiled rather than beforehand, which is later than anything resolving them in the same compilation needs. The task now takes a collection of directories, defaulting to the one it scanned before, and the generator can add to an index rather than always replacing it, so several directories contribute to one index instead of each erasing the last. --- .../gsp/GenerateTagLibraryIndexTask.groovy | 43 ++++++++++++------- .../plugin/views/gsp/GroovyPagePlugin.groovy | 2 +- .../GenerateTagLibraryIndexTaskSpec.groovy | 17 +++++++- .../index/TagLibraryIndexGenerator.java | 26 +++++++++-- .../index/TagLibraryIndexGeneratorSpec.groovy | 16 +++++++ 5 files changed, 83 insertions(+), 21 deletions(-) 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 index 5a0301d14e0..ff6341e45b9 100644 --- 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 @@ -29,7 +29,7 @@ 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.InputDirectory +import org.gradle.api.tasks.InputFiles import org.gradle.api.tasks.Optional import org.gradle.api.tasks.OutputDirectory import org.gradle.api.tasks.PathSensitive @@ -69,13 +69,18 @@ abstract class GenerateTagLibraryIndexTask extends DefaultTask { } /** - * The directory holding tag library sources, normally {@code grails-app/taglib}. + * 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. */ - @InputDirectory + @InputFiles @SkipWhenEmpty @IgnoreEmptyDirectories @PathSensitive(PathSensitivity.RELATIVE) - abstract DirectoryProperty getSourceDirectory() + abstract ConfigurableFileCollection getSourceDirectories() /** * Where the index is written. Placed on the compile classpath and packaged with the artifact. @@ -106,18 +111,26 @@ abstract class GenerateTagLibraryIndexTask extends DefaultTask { @TaskAction void generate() { - File source = sourceDirectory.get().asFile File destination = destinationDirectory.get().asFile destination.mkdirs() - execOperations.javaexec { JavaExecSpec spec -> - spec.mainClass.set(GENERATOR_CLASS) - spec.classpath = generatorClasspath - spec.args( - source.canonicalPath, - destination.canonicalPath, - String.valueOf(parameterNamesRetained.getOrElse(true)), - sourceEncoding.getOrElse('UTF-8') - ) - }.assertNormalExitValue() + List directories = new ArrayList(sourceDirectories.files.findAll { File dir -> dir.isDirectory() }) + if (!directories) { + return + } + directories.eachWithIndex { File source, int position -> + execOperations.javaexec { JavaExecSpec spec -> + spec.mainClass.set(GENERATOR_CLASS) + spec.classpath = generatorClasspath + spec.args( + source.canonicalPath, + destination.canonicalPath, + String.valueOf(parameterNamesRetained.getOrElse(true)), + sourceEncoding.getOrElse('UTF-8'), + // Only the first pass clears what was written before, so that several source + // directories contribute to one index rather than each erasing the last. + String.valueOf(position == 0) + ) + }.assertNormalExitValue() + } } } 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 31f7c8e053b..08d090e97b6 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 @@ -103,7 +103,7 @@ class GroovyPagePlugin implements Plugin { // output would make it wait for the compilation it is meant to precede. Provider tagLibIndexDir = project.layout.buildDirectory.dir('generated/grails-taglibs') def generateTagLibraryIndex = tasks.register('generateTagLibraryIndex', GenerateTagLibraryIndexTask) { - it.sourceDirectory.set(project.layout.projectDirectory.dir('grails-app/taglib')) + it.sourceDirectories.from(project.layout.projectDirectory.dir('grails-app/taglib')) it.destinationDirectory.set(tagLibIndexDir) it.generatorClasspath.from(project.configurations.named('compileClasspath')) it.parameterNamesRetained.set(resolvePreserveParameterNames(project)) 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 index 2af513987e9..d22b2787dbf 100644 --- 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 @@ -67,8 +67,8 @@ class GenerateTagLibraryIndexTaskSpec extends Specification { GenerateTagLibraryIndexTask task = project.tasks.getByName('generateTagLibraryIndex') as GenerateTagLibraryIndexTask expect: - task.sourceDirectory.get().asFile.canonicalFile == - new File(projectDir.toFile(), 'grails-app/taglib').canonicalFile + 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 } @@ -103,6 +103,19 @@ class GenerateTagLibraryIndexTaskSpec extends Specification { 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') 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 index f24b880c301..ec2460362c4 100644 --- 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 @@ -62,7 +62,8 @@ private TagLibraryIndexGenerator() { } /** - * @param args source directory, output directory, and whether parameter names are retained + * @param args source directory, output directory, whether parameter names are retained, the + * source encoding, and whether to discard an index already present */ public static void main(String[] args) throws IOException { if (args.length < 2) { @@ -73,7 +74,8 @@ public static void main(String[] args) throws IOException { File outputDir = new File(args[1]); boolean parameterNamesRetained = args.length < 3 || Boolean.parseBoolean(args[2]); String encoding = args.length > 3 && !args[3].isEmpty() ? args[3] : "UTF-8"; - generate(sourceDir, outputDir, parameterNamesRetained, encoding); + boolean clearExisting = args.length < 5 || Boolean.parseBoolean(args[4]); + generate(sourceDir, outputDir, parameterNamesRetained, encoding, clearExisting); } /** @@ -87,7 +89,25 @@ public static void main(String[] args) throws IOException { */ public static void generate(File sourceDir, File outputDir, boolean parameterNamesRetained, String encoding) throws IOException { - TagLibraryIndexWriter.clear(outputDir); + generate(sourceDir, outputDir, parameterNamesRetained, encoding, true); + } + + /** + * Regenerates the index, optionally adding to what is already there. + * + * @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 + * @param clearExisting whether to discard an index already present, which several source + * directories contributing to one index must do only on the first of them + * @throws IOException if the index cannot be written + */ + public static void generate(File sourceDir, File outputDir, boolean parameterNamesRetained, + String encoding, boolean clearExisting) throws IOException { + if (clearExisting) { + TagLibraryIndexWriter.clear(outputDir); + } if (sourceDir == null || !sourceDir.isDirectory()) { return; } 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 index ece517271ae..576b9ee2da0 100644 --- 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 @@ -148,6 +148,22 @@ class TagLibraryIndexGeneratorSpec extends Specification { manifest().isEmpty() } + void 'a second source directory adds to the index rather than replacing it'() { + 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: 'the first pass clears and the second adds' + TagLibraryIndexGenerator.generate(sources.toFile(), output.toFile(), true, 'UTF-8', true) + TagLibraryIndexGenerator.generate(other.toFile(), output.toFile(), true, 'UTF-8', false) + + then: 'both are described' + manifest() == ['FirstTagLib', 'SecondTagLib'] + descriptor('FirstTagLib').namespace == 'first' + descriptor('SecondTagLib').namespace == 'second' + } + private void write(String name, String source) { sources.resolve(name).toFile().text = source } From 37e5752e8f0fbe469625771e48cb078a257ac833 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 11 Aug 2026 00:30:40 -0600 Subject: [PATCH 20/74] Resolve tags without writing to a metaclass anywhere it matters Three places were still installing methods onto metaclasses, so the earlier claim that dispatching a tag writes to none of them was wider than what had actually been done. A page had methodMissing installed onto its metaclass as it compiled, along with a method for every tag and a property for every namespace. GroovyPage declares methodMissing itself now and already resolved a namespace through getProperty, so a page reaches the same tags without any of those writes. The template namespace installed a method for each template name the first time it was used. Rendering goes through the render tag either way, so the name is resolved rather than installed. The unit testing support keeps installing tag methods, deliberately. Tests call tag methods directly, and the installed methods substitute an empty body for a missing one, so tagLib.someTag(attrs, null) works. Removing it broke twelve FormTagLibTests cases that rely on that calling convention. A running application does not depend on it. NoMetaClassMutationSpec now covers the template namespace and the page, alongside the namespace dispatcher it already covered. --- .../groovy/org/grails/gsp/GroovyPage.java | 18 +++++++++++++ .../grails/gsp/GroovyPagesMetaUtils.groovy | 25 +++++++++---------- .../TemplateNamespacedTagDispatcher.groovy | 16 +++++------- .../web/taglib/NoMetaClassMutationSpec.groovy | 16 ++++++++++++ .../testing/web/GrailsWebUnitTest.groovy | 3 +++ 5 files changed, 55 insertions(+), 23 deletions(-) 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..6872dae4f21 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 @@ -49,6 +49,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; @@ -296,6 +297,23 @@ 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 + */ + public Object methodMissing(String name, Object args) { + return TagLibraryMetaUtils.methodMissingForTagLib(getMetaClass(), getClass(), gspTagLibraryLookup, + DEFAULT_NAMESPACE, name, args, false); + } + 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/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-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 index 985a241769a..6cf27331b1b 100644 --- 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 @@ -67,6 +67,22 @@ class NoMetaClassMutationSpec extends Specification { !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 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) { From 106aaae0366831e9df4baf20fe655ee8a8b35b54 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 11 Aug 2026 00:37:44 -0600 Subject: [PATCH 21/74] Record how each tag is implemented The index said only that a tag existed, which is enough to tell a misspelling from a real tag but not enough to decide whether a call to it can be bound. A tag defined as a Closure field carries no signature, so a call to it cannot become a direct invocation, and nothing in the index said which tags those were. Each tag is now recorded with its kind, and a call is only compiled into a direct invocation when the tag is a method. A closure-based tag stays known, so it is never reported as a misspelling, and stays dynamically dispatched. This showed up immediately: g.link is a Closure field, so calls to it are correctly left alone. The descriptor format is version 2 as a result. A descriptor written by another version is ignored rather than read under the wrong rules, and a kind that cannot be recognised is treated as the dynamic one so that a newer descriptor can never cause a call to be bound wrongly. --- .../discovery/TagLibraryAstDiscovery.java | 30 +++++++++++++++++ .../grails/taglib/index/TagLibraryIndex.java | 22 ++++++++++--- .../taglib/index/TagLibraryIndexEntry.java | 29 ++++++++++++++++- .../index/TagLibraryIndexGenerator.java | 5 ++- .../taglib/index/TagLibraryIndexWriter.java | 31 +++++++++++++++++- .../taglib/index/TagLibraryIndexSpec.groovy | 32 ++++++++++++++++++- .../compiler/CompiledTagCallRewriter.java | 8 ++++- .../TagLibArtefactTypeAstTransformation.java | 2 +- .../index/TagLibraryIndexGeneratorSpec.groovy | 23 +++++++++++-- .../taglib/CompiledTagCallBytecodeSpec.groovy | 22 ++++++++++++- .../ControllerTagCallRewriteSpec.groovy | 4 +-- 11 files changed, 191 insertions(+), 17 deletions(-) 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 index 86a4c1a1668..b0470f5161c 100644 --- 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 @@ -19,7 +19,9 @@ package org.grails.taglib.discovery; import java.util.Collection; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; +import java.util.Map; import java.util.Set; import groovy.lang.Closure; @@ -30,6 +32,8 @@ import org.codehaus.groovy.ast.expr.ConstantExpression; import org.codehaus.groovy.ast.expr.Expression; +import org.grails.taglib.index.TagLibraryIndexEntry; + /** * Reads a tag library's namespace and tag names from its syntax tree. * @@ -83,6 +87,32 @@ public static String resolveNamespace(ClassNode classNode) { * @param parameterNamesRetained whether this compilation writes parameter names into the class file * @return every tag the library declares, whether as a tag method or a legacy closure field */ + /** + * @param classNode the tag library + * @param parameterNamesRetained whether this compilation writes parameter names into the class file + * @return each tag mapped to how it is implemented, so that a caller can tell a tag it can bind to + * from one it must dispatch dynamically + */ + public static Map findTags(ClassNode classNode, + boolean parameterNamesRetained) { + Map tags = new LinkedHashMap<>(); + for (MethodNode method : classNode.getMethods()) { + if (method.getDeclaringClass() != null && !classNode.equals(method.getDeclaringClass())) { + continue; + } + if (TagDiscoveryRules.isTagMethod(new AstTagMethodView(method, parameterNamesRetained))) { + tags.put(method.getName(), TagLibraryIndexEntry.Kind.METHOD); + } + } + for (FieldNode field : classNode.getFields()) { + if (!field.isStatic() && field.getType() != null && CLOSURE_TYPE.equals(field.getType())) { + // A closure carries no signature, so a call to it cannot be bound when compiled. + tags.put(field.getName(), TagLibraryIndexEntry.Kind.LEGACY_CLOSURE); + } + } + return tags; + } + public static Collection findTagNames(ClassNode classNode, boolean parameterNamesRetained) { Set tagNames = new LinkedHashSet<>(); for (MethodNode method : classNode.getMethods()) { 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 index 369fa6639e5..3c2c89edd0e 100644 --- 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 @@ -60,7 +60,7 @@ public final class TagLibraryIndex { * 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; + public static final int FORMAT_VERSION = 2; static final String VERSION_KEY = "version"; static final String NAMESPACE_KEY = "namespace"; @@ -108,11 +108,24 @@ public static TagLibraryIndex load(ClassLoader classLoader) { } Map tagsForNamespace = merged.computeIfAbsent(namespace, k -> new TreeMap<>()); - for (String tagName : tags.split(",")) { - String trimmed = tagName.trim(); + for (String encodedTag : tags.split(",")) { + String trimmed = encodedTag.trim(); if (trimmed.isEmpty()) { continue; } + // Recorded as "name:KIND"; an unrecognised kind is treated as the dynamic one so that a + // descriptor from a later version cannot cause a call to be bound wrongly. + int separator = trimmed.lastIndexOf(':'); + String tagName = separator > 0 ? trimmed.substring(0, separator) : trimmed; + TagLibraryIndexEntry.Kind kind = TagLibraryIndexEntry.Kind.LEGACY_CLOSURE; + if (separator > 0) { + try { + kind = TagLibraryIndexEntry.Kind.valueOf(trimmed.substring(separator + 1)); + } catch (IllegalArgumentException unknownKind) { + kind = TagLibraryIndexEntry.Kind.LEGACY_CLOSURE; + } + } + trimmed = tagName; TagLibraryIndexEntry existing = tagsForNamespace.get(trimmed); if (existing != null && !existing.tagLibraryClassName().equals(className)) { // At runtime the tag library registered last wins, and registration order comes @@ -123,7 +136,8 @@ public static TagLibraryIndex load(ClassLoader classLoader) { ambiguous.computeIfAbsent(namespace, k -> new TreeSet<>()).add(trimmed); continue; } - tagsForNamespace.put(trimmed, new TagLibraryIndexEntry(namespace, trimmed, className)); + tagsForNamespace.put(trimmed, + new TagLibraryIndexEntry(namespace, trimmed, className, kind, true)); } } return new TagLibraryIndex(merged, ambiguous); 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 index 3c4974063d9..8ac8e51170e 100644 --- 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 @@ -24,7 +24,34 @@ * @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 kind how the tag is implemented, which decides whether a call to it can be resolved + * @param acceptsBody whether the tag can be called with a body * @since 8.0.0 */ -public record TagLibraryIndexEntry(String namespace, String tagName, String tagLibraryClassName) { +public record TagLibraryIndexEntry(String namespace, String tagName, String tagLibraryClassName, + Kind kind, boolean acceptsBody) { + + /** + * How a tag is implemented. + */ + public enum Kind { + + /** + * A method, which carries a signature and so can be bound when a caller is compiled. + */ + METHOD, + + /** + * A {@code Closure} field, the deprecated form. It carries no signature, so a call to it + * cannot be bound when the caller is compiled and is dispatched dynamically. + */ + LEGACY_CLOSURE + } + + /** + * @return true when a call to this tag can be compiled into a direct invocation + */ + public boolean isBindable() { + return kind == Kind.METHOD; + } } 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 index ec2460362c4..8084e67a50f 100644 --- 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 @@ -23,7 +23,6 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; -import java.util.Collection; import java.util.Comparator; import java.util.List; import java.util.stream.Stream; @@ -126,8 +125,8 @@ public static void generate(File sourceDir, File outputDir, boolean parameterNam // a guess. Left out, which leaves the tag library to runtime resolution. continue; } - Collection tagNames = TagLibraryAstDiscovery.findTagNames(classNode, parameterNamesRetained); - TagLibraryIndexWriter.write(outputDir, classNode.getName(), namespace, tagNames); + TagLibraryIndexWriter.write(outputDir, classNode.getName(), namespace, + TagLibraryAstDiscovery.findTags(classNode, parameterNamesRetained)); } } 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 index 289dad57966..f45670b25a2 100644 --- 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 @@ -28,7 +28,9 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.util.Collection; +import java.util.Map; import java.util.Properties; +import java.util.TreeMap; import java.util.TreeSet; /** @@ -81,6 +83,24 @@ public static void clear(File outputDirectory) throws IOException { */ public static void write(File outputDirectory, String className, String namespace, Collection tagNames) throws IOException { + Map asMethods = new TreeMap<>(); + for (String tagName : tagNames) { + asMethods.put(tagName, TagLibraryIndexEntry.Kind.METHOD); + } + write(outputDirectory, className, namespace, asMethods); + } + + /** + * Writes the descriptor for a tag library, recording how each tag is implemented. + * + * @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 tags each tag mapped to how it is implemented + * @throws IOException if the descriptor cannot be written + */ + public static void write(File outputDirectory, String className, String namespace, + Map tags) throws IOException { if (outputDirectory == null || className == null || className.isEmpty() || namespace == null || namespace.isEmpty()) { return; @@ -96,7 +116,16 @@ public static void write(File outputDirectory, String className, String namespac 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))); + // Recorded as "name:KIND" so that a caller can tell a tag it can bind to from one that has to + // be dispatched dynamically, without a second file or a nested format. + StringBuilder encoded = new StringBuilder(); + for (Map.Entry tag : new TreeMap<>(tags).entrySet()) { + if (encoded.length() > 0) { + encoded.append(','); + } + encoded.append(tag.getKey()).append(':').append(tag.getValue().name()); + } + descriptor.setProperty(TagLibraryIndex.TAGS_KEY, encoded.toString()); store(new File(indexDirectory, className + ".properties"), descriptor); File manifest = new File(indexDirectory, "index.properties"); 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 index 6c26ae2b9e9..c7c83c94263 100644 --- 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 @@ -142,6 +142,34 @@ class TagLibraryIndexSpec extends Specification { loader.close() } + void 'a closure based tag is recorded but is not bindable'() { + 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:METHOD,asClosure:LEGACY_CLOSURE\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: 'only the method form can be compiled into a direct invocation' + index.lookup('legacy', 'asMethod').isBindable() + !index.lookup('legacy', 'asClosure').isBindable() + + cleanup: + loader.close() + } + void 'a descriptor written by a different format version is ignored'() { given: Path other = tempDir.resolve('future.jar') @@ -170,8 +198,10 @@ class TagLibraryIndexSpec extends Specification { jar.closeEntry() tagLibs.each { String className, List namespaceAndTags -> jar.putNextEntry(new JarEntry(TagLibraryIndex.INDEX_LOCATION + className + '.properties')) + String encodedTags = namespaceAndTags[1].split(',') + .collect { "${it}:METHOD" }.join(',') jar.write(("version=${TagLibraryIndex.FORMAT_VERSION}\nclass=${className}\n" + - "namespace=${namespaceAndTags[0]}\ntags=${namespaceAndTags[1]}\n").bytes) + "namespace=${namespaceAndTags[0]}\ntags=${encodedTags}\n").bytes) jar.closeEntry() } } 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 index cfdc7932155..4256a324faf 100644 --- 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 @@ -38,6 +38,7 @@ import org.grails.taglib.CompiledTagInvocation; import org.grails.taglib.index.TagLibraryIndex; +import org.grails.taglib.index.TagLibraryIndexEntry; /** * Rewrites a call to a known tag into a direct invocation. @@ -114,10 +115,15 @@ private Expression rewriteTagCall(MethodCallExpression call) { return null; } String tagName = methodName.getValue().toString(); - if (index.lookup(namespace, tagName) == null) { + TagLibraryIndexEntry entry = index.lookup(namespace, tagName); + if (entry == null) { // Unknown here, or declared by more than one tag library and so deliberately unresolved. return null; } + if (!entry.isBindable()) { + // A closure-based tag carries no signature to bind to, so it keeps being dispatched. + return null; + } // A field or property of the same name as the namespace is that member, not a tag library. if (classNode.getDeclaredField(namespace) != null) { return null; 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 2ea4ace5c9e..4706ffe4f8c 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 @@ -85,7 +85,7 @@ protected void writeIndexEntry(SourceUnit sourceUnit, ClassNode classNode) { boolean parameterNamesRetained = sourceUnit.getConfiguration().getParameters(); try { TagLibraryIndexWriter.write(targetDirectory, classNode.getName(), namespace, - TagLibraryAstDiscovery.findTagNames(classNode, parameterNamesRetained)); + TagLibraryAstDiscovery.findTags(classNode, parameterNamesRetained)); } catch (IOException | RuntimeException e) { GrailsASTUtils.warning(sourceUnit, classNode, "Could not write the tag library index entry for [" + classNode.getName() + "]: " + 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 index 576b9ee2da0..375ab3ef509 100644 --- 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 @@ -41,6 +41,25 @@ class TagLibraryIndexGeneratorSpec extends Specification { 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:LEGACY_CLOSURE,asMethod:METHOD' + } + 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', ''' @@ -59,7 +78,7 @@ class TagLibraryIndexGeneratorSpec extends Specification { then: 'its tags are described from the source alone' descriptor('ExplosiveTagLib').namespace == 'boom' - descriptor('ExplosiveTagLib').tags == 'alpha,beta' + descriptor('ExplosiveTagLib').tags == 'alpha:METHOD,beta:METHOD' } void 'a renamed tag library leaves nothing behind'() { @@ -130,7 +149,7 @@ class TagLibraryIndexGeneratorSpec extends Specification { then: 'the one that reads is described' descriptorFile('FineTagLib').exists() - descriptor('FineTagLib').tags == 'present' + descriptor('FineTagLib').tags == 'present:METHOD' and: 'the one that does not is left out, to be described when it is compiled' !descriptorFile('UnresolvableTagLib').exists() 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 index 0c220ede2ad..83c9508334e 100644 --- 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 @@ -44,7 +44,7 @@ class CompiledTagCallBytecodeSpec extends Specification { class BytecodeCheckTagLib { static namespace = 'bytecheck' def calls(Map attrs) { - out << g.link(controller: 'book') + out << g.createLink(controller: 'book') } } ''', 'BytecodeCheckTagLib') @@ -70,6 +70,26 @@ class CompiledTagCallBytecodeSpec extends Specification { !references(compiled, 'org/grails/taglib/CompiledTagInvocation') } + void 'a closure based tag is left dynamic even though it is known'() { + when: 'g.link is declared as a Closure field, so it carries no signature to bind to' + byte[] 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, 'org/grails/taglib/CompiledTagInvocation') + + and: 'it is still known, so it is never reported as a misspelling' + TagLibraryIndex.load(getClass().classLoader).lookup('g', 'link') != null + } + 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', 'link') != null 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 index 10679128461..0606ff96b8b 100644 --- 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 @@ -43,7 +43,7 @@ class ControllerTagCallRewriteSpec extends Specification { import grails.artefact.gsp.TagLibraryInvoker class TagCallingController implements TagLibraryInvoker { def index() { - g.link(controller: 'book') + g.createLink(controller: 'book') } } ''', 'TagCallingController') @@ -57,7 +57,7 @@ class ControllerTagCallRewriteSpec extends Specification { byte[] compiled = compile(''' class PlainService { def index() { - g.link(controller: 'book') + g.createLink(controller: 'book') } } ''', 'PlainService') From 04540b6b6d47d9465e546b165e3067c68f920a58 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 11 Aug 2026 08:39:52 -0600 Subject: [PATCH 22/74] Remove an import left over from dropping the bootstrap metaclass enhancement --- .../groovy/org/grails/plugins/web/GroovyPagesGrailsPlugin.groovy | 1 - 1 file changed, 1 deletion(-) 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 98b4eb3de3b..8aa5c2cea55 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 @@ -52,7 +52,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 From 1bf67a36b70a0ac9a500f33e2b620c315040faa4 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 11 Aug 2026 08:39:53 -0600 Subject: [PATCH 23/74] Only treat a name as a namespace when nothing else claims it A namespace is not declared anywhere: it is reached because nothing else answers to the name. The rewriter took any receiver that was not this or super as a namespace, checking only for a field of that name on the class itself, so a local variable, a parameter, an inherited field or a getter-only property called g had calls on it rewritten into tag invocations. The object the author wrote was then never called, and the code still compiled, which is the worst way for this to go wrong. A receiver that resolves to anything - a local, a parameter, a field, a property - is that thing, and the field and property checks now walk the hierarchy and consider getters. Rewriting is also confined to methods declared by the class being transformed. getMethods() reaches inherited methods, whose bodies belong to the class that declared them, so a subclass able to call tags could otherwise change a superclass that cannot. TagCallShadowingSpec covers a local, a parameter, a typed local, a field, an inherited method, and the unshadowed case that must still be rewritten. --- .../compiler/CompiledTagCallRewriter.java | 40 +++++- .../web/taglib/TagCallShadowingSpec.groovy | 120 ++++++++++++++++++ 2 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/TagCallShadowingSpec.groovy 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 index 4256a324faf..c893348a498 100644 --- 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 @@ -23,7 +23,9 @@ import org.codehaus.groovy.ast.ClassCodeExpressionTransformer; import org.codehaus.groovy.ast.ClassHelper; import org.codehaus.groovy.ast.ClassNode; +import org.codehaus.groovy.ast.DynamicVariable; import org.codehaus.groovy.ast.MethodNode; +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; @@ -82,6 +84,12 @@ public int getRewrittenCount() { public void rewrite() { 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()) { visitClassCodeContainer(method.getCode()); } @@ -124,8 +132,7 @@ private Expression rewriteTagCall(MethodCallExpression call) { // A closure-based tag carries no signature to bind to, so it keeps being dispatched. return null; } - // A field or property of the same name as the namespace is that member, not a tag library. - if (classNode.getDeclaredField(namespace) != null) { + if (isShadowed(call.getObjectExpression(), namespace)) { return null; } @@ -190,4 +197,33 @@ private static String namespaceOf(Expression objectExpression) { } 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 classNode.getField(namespace) != null || + classNode.getProperty(namespace) != null || + hasGetter(namespace); + } + + private boolean hasGetter(String namespace) { + String getterName = "get" + Character.toUpperCase(namespace.charAt(0)) + namespace.substring(1); + return !classNode.getMethods(getterName).isEmpty(); + } } 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..461315f7858 --- /dev/null +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/TagCallShadowingSpec.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.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' + } + + 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') + } + + 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')) + } +} From 9729ec1ff15fd18c346dc18e34bea8622490b7bb Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 11 Aug 2026 08:39:53 -0600 Subject: [PATCH 24/74] Let a project resolve the tags it declares as it compiles The index a project generates from its own sources reached page compilation and the packaged artifact, but not the compilation of its own controllers and tag libraries. Those could resolve tags from dependencies while a call to a tag declared in the same project stayed dynamic, which is not what the documentation described. The generated directory now joins the compile classpath, and compileGroovy waits for it. It goes onto the classpath rather than into the source set output, which would make the index wait for the compilation it exists to precede. The documentation is also narrowed to what is actually rewritten. Expressions in a GSP page are checked against the descriptions but are not rewritten: a page selects the tag by name as it renders, through the namespace dispatcher, which no longer touches a metaclass but is still a runtime choice. An unqualified call such as message(code: 'x') is likewise left alone, since whether that name is a tag or a method of the calling class is decided where it is called. The examples now use a method-based tag, since the closure-based g.link they used is one of the calls that is deliberately not rewritten. --- .../src/en/guide/introduction/whatsNew.adoc | 14 ++++---- .../theWebLayer/gsp/taglibs/compiledTags.adoc | 33 +++++++++++++++---- .../src/en/guide/upgrading/upgrading80x.adoc | 1 - .../plugin/views/gsp/GroovyPagePlugin.groovy | 10 ++++++ .../GenerateTagLibraryIndexTaskSpec.groovy | 12 +++++++ 5 files changed, 57 insertions(+), 13 deletions(-) diff --git a/grails-doc/src/en/guide/introduction/whatsNew.adoc b/grails-doc/src/en/guide/introduction/whatsNew.adoc index b81caeaa7c4..3efe94b4eb0 100644 --- a/grails-doc/src/en/guide/introduction/whatsNew.adoc +++ b/grails-doc/src/en/guide/introduction/whatsNew.adoc @@ -281,9 +281,10 @@ guide for 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. 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 or dispatcher metaclasses to make dispatch work: +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] ---- @@ -296,11 +297,12 @@ class BookController { A tag that no compiled tag library declares is reported as a compilation warning, which `-Dgrails.views.gsp.strictTagChecking=true` turns into an error. Calls that cannot be resolved when -compiled — attributes assembled at runtime, a namespace no compiled tag library declares, or a tag -declared by more than one of them — are dispatched exactly as before. +compiled — attributes assembled at runtime, a namespace no compiled tag library declares, a tag +declared by more than one of them, a tag defined as a closure, or a name something else in scope +answers to — are dispatched exactly as before. Expressions in a GSP page are checked the same way but +are not rewritten; a page still selects the tag by name as it renders, without touching a metaclass. Defining a tag as a `Closure` field remains supported but is deprecated and now warns at compile time: a closure has no signature to resolve against, so calls to such a tag stay dynamic. 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 index 0155c450ce1..99829509fac 100644 --- a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc +++ b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc @@ -42,15 +42,15 @@ one produces a warning naming the tag. ==== Calling tags -A call to a tag whose namespace and name are known is compiled into a direct invocation rather than -being dispatched through the metaclass: +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.link(controller: 'book') // compiled into a direct invocation - String other = greet.hello(name: 'Grails') // likewise + String markup = g.createLink(controller: 'book') // compiled into a direct invocation + String other = greet.hello(name: 'Grails') // likewise } } ---- @@ -61,11 +61,32 @@ call whose attributes are assembled at runtime is dispatched as before: [source,groovy] ---- Map attrs = buildAttributes() -g.link(attrs) // dispatched dynamically +g.createLink(attrs) // dispatched dynamically ---- So is a call into a namespace no compiled tag library declares, which is what allows a tag library -registered while an application is running to keep working. +registered while an application is running to keep working, and so is a call to a tag defined as a +`Closure` field, which carries no signature to bind to. + +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 +} +---- + +Only a call naming its namespace is rewritten. An unqualified call, as `message(code: 'x')` is, is +dispatched as before, because whether such a name is a tag or a method of the calling class is decided +where it is called rather than by the tag libraries on the classpath. + +Expressions in a GSP page are checked against the same descriptions when the page is compiled, so a +misspelled tag is reported there too. They are not rewritten: a page reaches its tags through the +namespace dispatcher, which resolves the tag by name at render time. It does so without installing +anything onto a metaclass, but the tag is selected when the page runs rather than when it compiles. ==== Reporting unknown tags diff --git a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc index 704e7f272ed..0cc3163c7e1 100644 --- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc +++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc @@ -2287,4 +2287,3 @@ def hello(Map attrs) { out << "Hello ${attrs.name}" } ---- - 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 08d090e97b6..46cb65a0ea8 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 @@ -24,6 +24,7 @@ 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 @@ -111,6 +112,15 @@ class GroovyPagePlugin implements Plugin { mainSourceSet?.resources?.srcDir(tagLibIndexDir) tasks.named('processResources').configure { it.dependsOn(generateTagLibraryIndex) } + // 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. + tasks.named('compileGroovy', GroovyCompile).configure { GroovyCompile compile -> + compile.dependsOn(generateTagLibraryIndex) + compile.classpath = compile.classpath.plus(project.files(tagLibIndexDir)) + } + def compileGroovyPages = tasks.register('compileGroovyPages', GroovyPageForkCompileTask) { it.destinationDirectory.set(destDir) it.tmpDirPath = getTmpDirPath(project) 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 index d22b2787dbf..a0766021c03 100644 --- 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 @@ -78,6 +78,18 @@ class GenerateTagLibraryIndexTaskSpec extends Specification { 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') From 32a939307455d7c00eb67843b96437b5b1bdd59a Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 11 Aug 2026 08:50:20 -0600 Subject: [PATCH 25/74] Recognise a boolean getter as claiming a namespace name Groovy reads a property from getX() and, when the return type is boolean, from isX() as well. Only the first was checked, so a class declaring boolean isG() had this.g treated as a tag library namespace and calls on it rewritten, sending them to a tag library instead of the property the author wrote. Both forms now claim the name, with the isX form requiring a boolean return type as Groovy does. TagCallShadowingSpec covers each getter form and an inherited getter. Also proves the resolution the previous commit exists to enable. Generating an index from a tag library source, putting it on a compile classpath and compiling a controller that calls that namespace shows the call becoming an invocation, and shows it staying dynamic without the index. The build wiring is asserted separately; what was missing was evidence that the wiring is sufficient for the compiler to resolve the call. --- .../compiler/CompiledTagCallRewriter.java | 18 ++- .../SameProjectTagResolutionSpec.groovy | 129 ++++++++++++++++++ .../web/taglib/TagCallShadowingSpec.groovy | 39 ++++++ 3 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/SameProjectTagResolutionSpec.groovy 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 index c893348a498..1042d865ff8 100644 --- 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 @@ -222,8 +222,22 @@ private boolean isShadowed(Expression objectExpression, String namespace) { hasGetter(namespace); } + /** + * 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 getterName = "get" + Character.toUpperCase(namespace.charAt(0)) + namespace.substring(1); - return !classNode.getMethods(getterName).isEmpty(); + 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; } } 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 index 461315f7858..ff4ecafa19f 100644 --- 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 @@ -65,6 +65,45 @@ class TagCallShadowingSpec extends Specification { '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 'a call on the namespace itself is still rewritten'() { when: 'nothing in scope claims the name' byte[] compiled = compile(''' From 544710fd1d9bd512433cb7983547c03c980757d2 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 11 Aug 2026 10:07:13 -0600 Subject: [PATCH 26/74] Add the Apache license header to the compiled tags guide page --- .../theWebLayer/gsp/taglibs/compiledTags.adoc | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc index 99829509fac..64d38bff977 100644 --- a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc +++ b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc @@ -1,3 +1,22 @@ +//// +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. From 515c4b5f809952e3435c852c2f7c0e72d7c50ebc Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 11 Aug 2026 12:57:42 -0600 Subject: [PATCH 27/74] Read the tag library index once per compilation Reading walks every jar on the classpath. A compiler that consulted the index for each source file walked it once per file, while the one caller that did cache it held the result in a static field, which carried one project's tag libraries into the next compilation in the same Gradle daemon and read them from the wrong class loader. It is read once per class loader instead, which is once per compilation and no longer. Asking what a single tag library declares is answered from its own descriptor. It used to be answered by scanning every namespace and then discarding the answer entirely if any namespace anywhere held a tag two libraries declared, so one overridden tag left every tag library undescribed. A tag two libraries declare is now reported as known even though it cannot say which of them will answer to it, so that it is never mistaken for a misspelling, and the settings a build states about its tag libraries are read alongside the descriptors. --- .../grails/taglib/index/TagLibraryIndex.java | 163 +++++++++++++++--- .../taglib/index/TagLibraryIndexSpec.groovy | 85 +++++++++ 2 files changed, 226 insertions(+), 22 deletions(-) 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 index 3c2c89edd0e..d0554ff0426 100644 --- 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 @@ -33,6 +33,7 @@ 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. @@ -62,18 +63,61 @@ public final class TagLibraryIndex { */ public static final int FORMAT_VERSION = 2; + /** + * 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"; + 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 DYNAMIC_NAMESPACES_KEY = "dynamicTagNamespaces"; + + /** + * 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 TagLibraryIndex(Map> byNamespace, - Map> ambiguousByNamespace) { + Map> ambiguousByNamespace, Map> tagNamesByClass, + boolean strict, Set dynamicNamespaces) { this.byNamespace = byNamespace; this.ambiguousByNamespace = ambiguousByNamespace; + this.tagNamesByClass = tagNamesByClass; + this.strict = strict; + this.dynamicNamespaces = dynamicNamespaces; + } + + /** + * 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); } /** @@ -86,8 +130,9 @@ 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); + return new TagLibraryIndex(merged, ambiguous, byClass, 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 @@ -126,6 +171,10 @@ public static TagLibraryIndex load(ClassLoader classLoader) { } } trimmed = tagName; + // 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 @@ -140,7 +189,29 @@ public static TagLibraryIndex load(ClassLoader classLoader) { new TagLibraryIndexEntry(namespace, trimmed, className, kind, true)); } } - return new TagLibraryIndex(merged, ambiguous); + Properties settings = readSettings(loader); + boolean strict = Boolean.parseBoolean(settings.getProperty(STRICT_KEY, "false")); + Set dynamic = new TreeSet<>(); + for (String namespace : settings.getProperty(DYNAMIC_NAMESPACES_KEY, "").split(",")) { + String trimmed = namespace.trim(); + if (!trimmed.isEmpty()) { + dynamic.add(trimmed); + } + } + return new TagLibraryIndex(merged, ambiguous, byClass, strict, Collections.unmodifiableSet(dynamic)); + } + + /** + * 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) { @@ -244,33 +315,81 @@ public Set getTagNames(String namespace) { /** * The tags a given tag library declares, as recorded when it was compiled. * - *

Lets a tag library be registered without discovering its tags by reflection, which otherwise - * means touching the metaclass of every tag library as an application starts. + *

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, in which case the caller must - * discover them itself + * @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 = new TreeSet<>(); - for (Map tags : byNamespace.values()) { - for (TagLibraryIndexEntry entry : tags.values()) { - if (tagLibraryClassName.equals(entry.tagLibraryClassName())) { - tagNames.add(entry.tagName()); - } - } - } - // A tag this class declares that another also declares is ambiguous and was not recorded - // against either, so fall back to discovery rather than register an incomplete set. - for (Set ambiguousTags : ambiguousByNamespace.values()) { - if (!ambiguousTags.isEmpty() && !tagNames.isEmpty()) { - 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} + */ + 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; + } + + /** + * @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; } - return Collections.unmodifiableSet(tagNames); + Map tags = byNamespace.get(namespace); + return tags != null && tags.containsKey(tagName); } /** 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 index c7c83c94263..8b9ebf7de8d 100644 --- 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 @@ -190,6 +190,91 @@ class TagLibraryIndexSpec extends Specification { 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 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 -> From 1235c4cf1a2c5e8b50ad2c31aef6f9b31b20c458 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 11 Aug 2026 12:58:11 -0600 Subject: [PATCH 28/74] Register the tags a tag library actually has Registration preferred the tags recorded when a tag library was compiled over the tags the class has, to save discovering them by reflection as the application starts. It saved nothing: the tag library class is constructed before it is registered, and constructing it already reads every tag by reflection and through the metaclass. What it did add was a way for a descriptor left behind by an earlier build to decide what a running application believes a tag library declares. Reflection is authoritative at runtime; the index describes what was true when the tag library was compiled and is used where that is the question being asked. --- .../org/grails/taglib/TagLibraryLookup.java | 38 +------------------ 1 file changed, 1 insertion(+), 37 deletions(-) diff --git a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibraryLookup.java b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibraryLookup.java index a1976fede93..0558601c209 100644 --- a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibraryLookup.java +++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibraryLookup.java @@ -18,7 +18,6 @@ */ package org.grails.taglib; -import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; @@ -40,7 +39,6 @@ import org.grails.core.artefact.gsp.TagLibArtefactHandler; import org.grails.core.exceptions.GrailsConfigurationException; import org.grails.taglib.encoder.WithCodecHelper; -import org.grails.taglib.index.TagLibraryIndex; /** * Looks up tag library instances. @@ -56,14 +54,6 @@ public class TagLibraryLookup implements ApplicationContextAware, GrailsApplicat protected Map> tagsThatReturnObjectForNamespace = new LinkedHashMap<>(); protected Map>> encodeAsForTagNamespaces = new LinkedHashMap<>(); - /** - * The tags recorded when the tag libraries on the classpath were compiled. Registering from these - * avoids discovering tags by reflecting over, and touching the metaclass of, every tag library as - * the application starts. A tag library without a descriptor, as one from a plugin built before - * the index existed or one registered while developing, is discovered the previous way. - */ - private TagLibraryIndex tagLibraryIndex; - public void afterPropertiesSet() throws Exception { if (grailsApplication == null || applicationContext == null) { return; @@ -129,7 +119,7 @@ private void registerTagLib(GrailsTagLibClass taglib, boolean isInitialization) tagNamespaces.put(namespace, tags); } - for (String tagName : resolveTagNames(taglib, isInitialization)) { + for (String tagName : taglib.getTagNames()) { putTagLib(tags, tagName, taglib); tagsThatReturnObject.remove(tagName); } @@ -157,32 +147,6 @@ private void registerTagLib(GrailsTagLibClass taglib, boolean isInitialization) } } - /** - * Prefers the tags recorded when the tag library was compiled, falling back to discovering them - * from the class when it has no descriptor. - */ - private Collection resolveTagNames(GrailsTagLibClass taglib, boolean isInitialization) { - if (!isInitialization) { - // Registering after startup means the tag library has been supplied directly, as reloading - // a changed class during development and registering one from a test both do. The - // descriptor describes the class as it was compiled, which is no longer what is being - // registered, so the class itself is asked. - return taglib.getTagNames(); - } - if (tagLibraryIndex == null) { - tagLibraryIndex = TagLibraryIndex.load(resolveClassLoader()); - } - Set indexed = tagLibraryIndex.getTagNamesForClass(taglib.getClazz().getName()); - return !indexed.isEmpty() ? indexed : taglib.getTagNames(); - } - - private ClassLoader resolveClassLoader() { - if (grailsApplication != null && grailsApplication.getClassLoader() != null) { - return grailsApplication.getClassLoader(); - } - return getClass().getClassLoader(); - } - protected void putTagLib(Map tags, String name, GrailsTagLibClass taglib) { tags.put(name, applicationContext.getBean(taglib.getFullName())); } From b1ab9a42a6aa03d60f3542334e93dc195b521739 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 11 Aug 2026 12:58:11 -0600 Subject: [PATCH 29/74] Invoke a resolved tag with the arguments it was called with A tag call takes more shapes than attributes and a body: nothing at all, a body alone, a single value the tag reads under its own name, or attributes only known once they have been evaluated. Only the shapes evident in the source could be expressed as an invocation, so the rest stayed dynamic. The arguments are forwarded as written and adapted the same way, and in the same order, that dynamic dispatch adapts them, including how it treats an argument list matching none of the shapes it knows. --- .../grails/taglib/CompiledTagInvocation.java | 71 +++++++++++++++++++ .../taglib/CompiledTagInvocationSpec.groovy | 41 +++++++++++ 2 files changed, 112 insertions(+) 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 index c05cb6ae748..e2c6317070a 100644 --- 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 @@ -19,8 +19,11 @@ 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; @@ -41,6 +44,8 @@ */ public final class CompiledTagInvocation { + private static final Object[] EMPTY_ARGUMENTS = new Object[0]; + private CompiledTagInvocation() { } @@ -83,4 +88,70 @@ public static Object invoke(TagLibraryLookup lookup, String namespace, String ta 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/plugin/src/test/groovy/org/grails/web/taglib/CompiledTagInvocationSpec.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/CompiledTagInvocationSpec.groovy index 82c7fde544d..8f5517ecbff 100644 --- 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 @@ -69,4 +69,45 @@ class CompiledTagInvocationSpec extends Specification implements TagLibUnitTest< 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') + } } From b204c78c35c70cb2212284e6de8fb5da66cb775d Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 11 Aug 2026 13:00:23 -0600 Subject: [PATCH 30/74] Resolve a tag call wherever it is written A tag call written inside a closure was never resolved. Groovy's expression transformer does not descend into closures and documents the override that reaches them, which was missing, so a tag called in a tag body, in a withFormat block, or in anything else taking a block stayed dynamic. That is where most tag calls in a real tag library are. Calls in a constructor and in a field initialiser were not reached either. A call written without a namespace is resolved too. It 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, parameter or local - and is then offered to the calling tag library's own namespace before the default one, which is the order dispatch uses at runtime. Scope is not tracked within a body: a name declared anywhere in one is treated as claimed throughout it, which can leave a call dispatched dynamically but never sends one somewhere the author did not write. A tag two libraries declare, and a tag declared as a closure field, are resolved as well. Neither can say which implementation will run, but neither has to: the invocation selects the tag by name at runtime through the same lookup, so registration order and overriding decide it exactly as they did. A namespace the build has declared as filled in while the application runs is left alone entirely, which is the escape hatch for tags decided at runtime rather than described at compile time. --- .../compiler/CompiledTagCallRewriter.java | 334 ++++++++++++++++-- .../taglib/compiler/LocalNameCollector.java | 107 ++++++ .../taglib/compiler/PageBindingCollector.java | 93 +++++ .../taglib/CompiledTagCallBytecodeSpec.groovy | 201 ++++++++++- 4 files changed, 686 insertions(+), 49 deletions(-) create mode 100644 grails-gsp/grails-web-taglib/src/main/groovy/grails/gsp/taglib/compiler/LocalNameCollector.java create mode 100644 grails-gsp/grails-web-taglib/src/main/groovy/grails/gsp/taglib/compiler/PageBindingCollector.java 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 index 1042d865ff8..ea8d75be2b4 100644 --- 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 @@ -18,13 +18,19 @@ */ package grails.gsp.taglib.compiler; +import java.util.Collections; import java.util.List; +import java.util.Set; +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; @@ -36,11 +42,13 @@ 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; -import org.grails.taglib.index.TagLibraryIndexEntry; /** * Rewrites a call to a known tag into a direct invocation. @@ -48,13 +56,16 @@ *

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 - * {@link CompiledTagInvocation#invoke}, an ordinary static method call. + * library index says whether that tag exists, so the call is replaced with a call to + * {@link CompiledTagInvocation}, an ordinary static method call. * - *

Only calls whose shape is evident from the source are rewritten: a tag takes attributes, a body, - * both or neither, and where the arguments cannot be recognised as that the call is left alone and - * resolves as it did before. A namespace the index does not know, or a tag it does not hold, is also - * left alone, which is what keeps a tag library registered at runtime working. + *

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 */ @@ -62,17 +73,50 @@ 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 the dispatch treats as its own before it ever considers a tag, so an unqualified call to + * one of them is not a tag call however the index reads. + */ + private static final Set RESERVED_NAMES = Set.of("body", "render"); + + 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(); + 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; } /** @@ -83,6 +127,16 @@ public int getRewrittenCount() { } 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 @@ -91,9 +145,36 @@ public void rewrite() { continue; } if (method.getCode() != null && !method.isAbstract()) { - visitClassCodeContainer(method.getCode()); + 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 @@ -103,41 +184,118 @@ protected SourceUnit getSourceUnit() { @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. + closure.visit(this); + 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) { - String namespace = namespaceOf(call.getObjectExpression()); - if (namespace == null || !index.hasNamespace(namespace)) { - return null; - } if (!(call.getMethod() instanceof ConstantExpression methodName) || methodName.getValue() == null) { return null; } String tagName = methodName.getValue().toString(); - TagLibraryIndexEntry entry = index.lookup(namespace, tagName); - if (entry == null) { - // Unknown here, or declared by more than one tag library and so deliberately unresolved. + 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; } - if (!entry.isBindable()) { - // A closure-based tag carries no signature to bind to, so it keeps being dispatched. + 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 (!call.isImplicitThis() || RESERVED_NAMES.contains(tagName)) { return null; } - if (isShadowed(call.getObjectExpression(), namespace)) { + 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; + } - Expression[] attrsAndBody = attributesAndBody(call.getArguments()); - if (attrsAndBody == 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(); @@ -145,19 +303,40 @@ private Expression rewriteTagCall(MethodCallExpression call) { LOOKUP_ACCESSOR, MethodCallExpression.NO_ARGUMENTS)); invocationArgs.addExpression(new ConstantExpression(namespace)); invocationArgs.addExpression(new ConstantExpression(tagName)); - invocationArgs.addExpression(attrsAndBody[0]); - invocationArgs.addExpression(attrsAndBody[1]); - return new StaticMethodCallExpression(INVOCATION_TYPE, INVOKE, invocationArgs); + + 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. + 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); + } + + private Expression outputContext() { + return new MethodCallExpression(VariableExpression.THIS_EXPRESSION, OUTPUT_CONTEXT_ACCESSOR, + MethodCallExpression.NO_ARGUMENTS); } /** - * @return the attributes and body to pass, or {@code null} when the arguments are not recognisably - * a tag call and it should be left to resolve as before + * @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(Expression arguments) { - if (!(arguments instanceof TupleExpression tuple)) { - return null; - } + private Expression[] attributesAndBody(TupleExpression tuple) { List args = tuple.getExpressions(); Expression noAttributes = new MapExpression(); Expression noBody = ConstantExpression.NULL; @@ -182,6 +361,53 @@ private Expression[] attributesAndBody(Expression arguments) { } } + /** + * 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; + } + 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 @@ -217,9 +443,25 @@ private boolean isShadowed(Expression objectExpression, String namespace) { } // 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 classNode.getField(namespace) != null || - classNode.getProperty(namespace) != null || - hasGetter(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(); } /** @@ -240,4 +482,32 @@ private boolean hasGetter(String namespace) { } return false; } + + /** + * 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..368d749fa5e --- /dev/null +++ b/grails-gsp/grails-web-taglib/src/main/groovy/grails/gsp/taglib/compiler/LocalNameCollector.java @@ -0,0 +1,107 @@ +/* + * 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.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 and the variables its loops introduce. + * + *

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 { + + 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()); + } + super.visitClosureExpression(expression); + } + + @Override + public void visitForLoop(ForStatement forLoop) { + if (forLoop.getVariable() != null) { + names.add(forLoop.getVariable().getName()); + } + super.visitForLoop(forLoop); + } +} 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/plugin/src/test/groovy/org/grails/web/taglib/CompiledTagCallBytecodeSpec.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/CompiledTagCallBytecodeSpec.groovy index 83c9508334e..9cbc0ef7f52 100644 --- 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 @@ -33,12 +33,14 @@ import spock.lang.TempDir */ 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: - byte[] compiled = compile(''' + Path compiled = compile(''' import grails.gsp.TagLib @TagLib class BytecodeCheckTagLib { @@ -50,12 +52,123 @@ class CompiledTagCallBytecodeSpec extends Specification { ''', 'BytecodeCheckTagLib') then: 'the invocation entry point is referenced' - references(compiled, 'org/grails/taglib/CompiledTagInvocation') + 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 compiled as an invocation'() { + when: 'nothing in the tag library answers to the name, so it reaches a tag' + Path compiled = compile(''' + import grails.gsp.TagLib + @TagLib + class UnqualifiedCallerTagLib { + static namespace = 'unqualified' + def calls(Map attrs) { + out << createLink(controller: 'book') + } + } + ''', 'UnqualifiedCallerTagLib') + + then: + 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: - byte[] compiled = compile(''' + Path compiled = compile(''' import grails.gsp.TagLib @TagLib class UntouchedTagLib { @@ -67,12 +180,12 @@ class CompiledTagCallBytecodeSpec extends Specification { ''', 'UntouchedTagLib') then: 'nothing was rewritten, so it resolves as it did before' - !references(compiled, 'org/grails/taglib/CompiledTagInvocation') + !references(compiled, 'UntouchedTagLib') } - void 'a closure based tag is left dynamic even though it is known'() { - when: 'g.link is declared as a Closure field, so it carries no signature to bind to' - byte[] compiled = compile(''' + 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 { @@ -84,34 +197,88 @@ class CompiledTagCallBytecodeSpec extends Specification { ''', 'ClosureTagCallerTagLib') then: - !references(compiled, 'org/grails/taglib/CompiledTagInvocation') + 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) - and: 'it is still known, so it is never reported as a misspelling' - TagLibraryIndex.load(getClass().classLoader).lookup('g', 'link') != null + 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', 'link') != null + 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(byte[] classBytes, String internalName) { - new String(classBytes, 'ISO-8859-1').contains(internalName) + 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 byte[] compile(String source, String className) { + 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')) + 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)) + new GroovyClassLoader(parent ?: getClass().classLoader, configuration)) unit.addSource(sourceFile.toFile()) unit.compile() - Files.readAllBytes(outputDir.resolve(className + '.class')) + outputDir } } From 960c9cc97ed6ab333682cbc7bfa699995691a45a Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 11 Aug 2026 13:00:38 -0600 Subject: [PATCH 31/74] Resolve a tag expression in a statically compiled page A page reached every tag through the namespace dispatcher, selecting it by name each time it rendered. Where the page names both the namespace and the tag, and the index knows them, there is nothing left to decide, so the expression is compiled into an invocation against the page's own output context. Only in a page that declares compileStatic. A page resolves a name against the model it was rendered with before it reaches a tag library, and that model is not visible when the page compiles, so a model attribute named after a namespace would silently stop winning. Declaring compileStatic is a page giving up dynamic resolution, and it is what reserves the namespace names. A page that has not declared it resolves its tags exactly as before. Two things hold either way. A call written without a namespace is left alone, for the same reason. And a name the page puts into its own binding with g:set is that variable rather than a namespace, which the page does say when it compiles. --- .../groovy/org/grails/gsp/GroovyPage.java | 13 ++ .../GroovyPageTypeCheckingExtension.groovy | 90 +++--------- .../CompiledTagCallTransformation.groovy | 24 ++- .../web/taglib/CompiledPageTagCallSpec.groovy | 138 ++++++++++++++++++ .../taglib/RewrittenPageRenderingSpec.groovy | 92 ++++++++++++ 5 files changed, 281 insertions(+), 76 deletions(-) create mode 100644 grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/CompiledPageTagCallSpec.groovy create mode 100644 grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/RewrittenPageRenderingSpec.groovy 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 6872dae4f21..b4491ddcc9e 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 @@ -256,6 +256,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 * 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 1fd8932a966..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 @@ -29,8 +29,6 @@ import org.codehaus.groovy.ast.expr.ListExpression import org.codehaus.groovy.ast.expr.PropertyExpression import org.codehaus.groovy.ast.expr.VariableExpression import org.codehaus.groovy.transform.stc.GroovyTypeCheckingExtensionSupport -import org.codehaus.groovy.control.SourceUnit -import org.codehaus.groovy.control.messages.WarningMessage import org.codehaus.groovy.transform.stc.StaticTypesMarker import org.grails.gsp.GroovyPage @@ -48,30 +46,16 @@ class GroovyPageTypeCheckingExtension extends GroovyTypeCheckingExtensionSupport * 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 turns a call to a misspelled tag from something - * deferred to runtime dispatch into a compilation error, and removes the need to declare - * namespaces by hand for tag libraries that were on the compile classpath. - */ - private static final TagLibraryIndex TAG_LIBRARY_INDEX = TagLibraryIndex.load( - GroovyPageTypeCheckingExtension.classLoader) - - /** - * Set to {@code true} to fail compilation on a tag no compiled tag library declares. - * - *

Off by default, because knowing that a namespace holds some compiled tag libraries is not the - * same as knowing it holds all of them. A plugin built before the index existed contributes tags - * to {@code g} without a descriptor, a tag library registered while an application runs - * contributes more, and the index generator skips a source it cannot resolve ahead of compilation. - * In each case the namespace is known but incomplete, and a tag missing from it is not necessarily - * a misspelling. Until a namespace can state that it is complete, an unrecognised tag is reported - * as a warning. + * 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. */ - public static final String STRICT_TAG_CHECKING_PROPERTY = 'grails.views.gsp.strictTagChecking' - - private static boolean isStrictTagChecking() { - // Read per report rather than cached: this is only reached once a tag has already failed to - // resolve, so it costs nothing on the common path and stays settable within a running compiler. - Boolean.getBoolean(STRICT_TAG_CHECKING_PROPERTY) + private TagLibraryIndex getTagLibraryIndex() { + TagLibraryIndex.forClassLoader(typeCheckingVisitor?.sourceUnit?.classLoader) } @Override @@ -92,7 +76,8 @@ class GroovyPageTypeCheckingExtension extends GroovyTypeCheckingExtensionSupport } } // Namespaces backed by a compiled tag library need no declaration: their tags are known. - currentScope.allowedTagLibs.addAll(TAG_LIBRARY_INDEX.namespaces) + currentScope.allowedTagLibs.addAll(tagLibraryIndex.namespaces) + currentScope.allowedTagLibs.addAll(tagLibraryIndex.dynamicNamespaces) } unresolvedProperty { PropertyExpression pe -> @@ -111,24 +96,26 @@ class GroovyPageTypeCheckingExtension extends GroovyTypeCheckingExtensionSupport methodNotFound { receiver, name, argList, argTypes, call -> if (isThisTheReceiver(call)) { - // An unqualified call in a GSP is a tag in the default namespace. When that namespace - // has compiled tag libraries, the tag has to be one of them. - reportUnknownTagIfIndexed(GroovyPage.DEFAULT_NAMESPACE, name, 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)) { - reportUnknownTagIfIndexed(namespaceNameOf(objectExpression), name, call) return makeDynamic(call) } // GROOVY-12041: Groovy 5 resolves receivers inherited through getProperty(String) as dynamic // before unresolvedVariable/unresolvedProperty can record them. Use the marker Groovy places on // those expressions, but still require the receiver name to be an allowed taglib namespace. if (isAllowedDynamicTaglibNamespace(objectExpression)) { - reportUnknownTagIfIndexed(namespaceNameOf(objectExpression), name, call) return makeDynamic(call) } if (objectExpression instanceof VariableExpression && isUndeclaredDynamicVariable(objectExpression)) { @@ -188,45 +175,4 @@ class GroovyPageTypeCheckingExtension extends GroovyTypeCheckingExtensionSupport def isThisTheReceiver(expr) { expr.implicitThis || (expr.objectExpression instanceof VariableExpression && expr.objectExpression.thisExpression) } - - /** - * Fails compilation when a namespace has compiled tag libraries but none of them declares the tag. - * - *

Silent when the namespace is unknown to the index, because a tag library registered at runtime - * or supplied by a plugin compiled separately is still legitimate and must keep resolving - * dynamically. - */ - private void reportUnknownTagIfIndexed(String namespace, String tagName, Expression call) { - if (namespace == null || !TAG_LIBRARY_INDEX.hasNamespace(namespace)) { - return - } - if (TAG_LIBRARY_INDEX.lookup(namespace, tagName) != null) { - return - } - if (TAG_LIBRARY_INDEX.isAmbiguous(namespace, tagName)) { - // Declared by more than one tag library, so which one runs is decided by registration - // order at runtime. The tag exists; it just cannot be bound here. - return - } - String message = "No such tag [${tagName}] in namespace [${namespace}]. Known tags: " + - TAG_LIBRARY_INDEX.getTagNames(namespace).join(', ') - if (isStrictTagChecking()) { - typeCheckingVisitor.addStaticTypeError(message, call) - return - } - // Reporting rather than failing, for a build that has opted out of the check. - SourceUnit sourceUnit = typeCheckingVisitor.sourceUnit - sourceUnit?.errorCollector?.addWarning( - new WarningMessage(WarningMessage.LIKELY_ERRORS, message, null, sourceUnit)) - } - - private static String namespaceNameOf(Expression objectExpression) { - if (objectExpression instanceof VariableExpression) { - return ((VariableExpression) objectExpression).name - } - if (objectExpression instanceof PropertyExpression) { - return ((PropertyExpression) objectExpression).propertyAsString - } - null - } } 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 index 94718279465..c70c0743cfd 100644 --- a/grails-gsp/plugin/src/ast/groovy/grails/compiler/traits/CompiledTagCallTransformation.groovy +++ b/grails-gsp/plugin/src/ast/groovy/grails/compiler/traits/CompiledTagCallTransformation.groovy @@ -38,7 +38,8 @@ import org.grails.taglib.index.TagLibraryIndex *

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. + * 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. @@ -51,6 +52,12 @@ class CompiledTagCallTransformation implements ASTTransformation { 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() @@ -63,7 +70,7 @@ class CompiledTagCallTransformation implements ASTTransformation { continue } if (index == null) { - index = TagLibraryIndex.load(source.getClassLoader()) + index = TagLibraryIndex.forClassLoader(source.getClassLoader()) if (index.isEmpty()) { return } @@ -74,9 +81,18 @@ class CompiledTagCallTransformation implements ASTTransformation { /** * @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 + * 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) { - classNode.implementsInterface(TAG_LIBRARY_INVOKER) || classNode.declaresInterface(TAG_LIBRARY_INVOKER) + 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 } } 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/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('''''') + } +} From 54773e74a67e51182df42f9f1263e9449ffa40e2 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 11 Aug 2026 13:00:38 -0600 Subject: [PATCH 32/74] Describe each tag library once Two things wrote descriptors: the build, generating the index from source before compiling, and each tag library describing itself as it compiled. Both reached the compile classpath and both were packaged, so a tag library renamed or deleted between builds could keep being described by the copy written class by class, which nothing cleans. A tag library now describes itself only when nothing already has. That is asked per tag library rather than per build, because generating the index ahead of compilation cannot always describe every one of them: a tag library referring to a class of the same project cannot be resolved before that project is compiled and is skipped there. Treating the build as authoritative for all of them would leave such a tag library described by nothing at all. --- .../TagLibArtefactTypeAstTransformation.java | 26 +++- .../index/SingleIndexProducerSpec.groovy | 126 ++++++++++++++++++ 2 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 grails-gsp/grails-web-taglib/src/test/groovy/org/grails/taglib/index/SingleIndexProducerSpec.groovy 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 4706ffe4f8c..684b3cb7bd5 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 @@ -73,6 +73,13 @@ protected void writeIndexEntry(SourceUnit sourceUnit, ClassNode classNode) { // descriptor; those callers resolve tags at runtime. return; } + if (alreadyDescribed(sourceUnit, classNode)) { + // The build described this tag library ahead of compiling it. A second copy written into + // the class output would be merged with that one on the classpath and packaged alongside + // it, and being written class by class it would keep describing the tag library after it + // had been renamed or deleted. One thing describes each tag library. + return; + } String namespace = TagLibraryAstDiscovery.resolveNamespace(classNode); if (namespace == null) { // The namespace is only known once the tag library's initialiser runs, so recording the @@ -93,12 +100,29 @@ protected void writeIndexEntry(SourceUnit sourceUnit, ClassNode classNode) { } } + /** + * Whether something has already described this tag library, which the Grails Gradle plugin does + * ahead of compiling by generating the index from source. + * + *

Asked per tag library rather than per build, because generating the index ahead of + * compilation cannot always describe every one of them: a tag library referring to a class of the + * same project that has not been compiled yet is skipped there. Treating the build as + * authoritative for all of them would leave such a tag library described by nothing at all. + */ + private static boolean alreadyDescribed(SourceUnit sourceUnit, ClassNode classNode) { + ClassLoader classLoader = sourceUnit.getClassLoader(); + if (classLoader == null) { + return false; + } + return TagLibraryIndex.forClassLoader(classLoader).isClassDescribed(classNode.getName()); + } + /** * 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.load(sourceUnit.getClassLoader()); + TagLibraryIndex index = TagLibraryIndex.forClassLoader(sourceUnit.getClassLoader()); if (index.isEmpty()) { return; } 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..8882f8ecf40 --- /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 the build already described writes no second descriptor'() { + 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 'a tag library the build could not describe still describes itself'() { + given: 'the build generated an index, but skipped this tag library, as an unresolvable one is' + Path output = compileWithIndexDescribing('some.other.TagLib', 'other', 'somethingElse') + + expect: 'otherwise it would be described by nothing at all and vanish from the index' + 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}:METHOD\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 + } +} From b9e2b510b6a9a1d952dc09298309d81281712878 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 11 Aug 2026 13:00:56 -0600 Subject: [PATCH 33/74] Report an unknown tag only where the source says it is a tag Reporting one by default meant complaining about correct code: a namespace holding some compiled tag libraries is not one holding all of them, and a plugin built before descriptors existed contributes tags to g without one. This framework calls such a tag itself, and warned about it on every build. Nothing is reported unless the build states that its tag libraries are all described. What is checked is a call the source shows to be a tag: one naming its namespace, and one written as markup. A call written without a namespace is never checked, here or in the type checking extension, because such a name may equally be a dynamic finder, an injected service method or anything else contributed while the application runs; in a page it may be part of the model. A namespaced expression in a page is checked only where it is resolved, in a page declaring compileStatic. --- ...LibraryInvokerTypeCheckingExtension.groovy | 7 ++ .../taglib/GspStaticTagResolutionSpec.groovy | 93 +++++++++++++++---- 2 files changed, 81 insertions(+), 19 deletions(-) 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-gsp/plugin/src/test/groovy/org/grails/web/taglib/GspStaticTagResolutionSpec.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/GspStaticTagResolutionSpec.groovy index 888677b13f0..3254e275b71 100644 --- 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 @@ -18,23 +18,47 @@ */ package org.grails.web.taglib +import java.nio.file.Files +import java.nio.file.Path + import org.grails.gsp.GroovyPagesTemplateEngine -import org.grails.gsp.compiler.GroovyPageTypeCheckingExtension 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 - * statically compiled GSP be checked against the tags that actually exist, rather than deferring every - * tag call to runtime dispatch. + * 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 = new GroovyPagesTemplateEngine() - gpte.afterPropertiesSet() + 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'() { @@ -44,7 +68,7 @@ class GspStaticTagResolutionSpec extends Specification { expect: index.hasNamespace('g') index.lookup('g', 'message') != null - index.lookup('g', 'link') != null + index.isKnown('g', 'link') } void 'a statically compiled page calling a known tag compiles'() { @@ -65,40 +89,71 @@ class GspStaticTagResolutionSpec extends Specification { when: def t = gpte.createTemplate(template, 'unknown-tag-lenient') - then: 'it is reported as a warning and the page still compiles' + 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 under strict checking'() { + void 'an unrecognised tag fails compilation when the build declares its tags complete'() { given: - System.setProperty(GroovyPageTypeCheckingExtension.STRICT_TAG_CHECKING_PROPERTY, 'true') + GroovyPagesTemplateEngine strict = engineFor('strictTags=true\n') String template = '''<%@ page compileStatic="true" %>${g.mesage(code: 'typo')}''' when: - def t = gpte.createTemplate(template, 'unknown-tag-strict') + 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]') + } - cleanup: - System.clearProperty(GroovyPageTypeCheckingExtension.STRICT_TAG_CHECKING_PROPERTY) + 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\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' - System.setProperty(GroovyPageTypeCheckingExtension.STRICT_TAG_CHECKING_PROPERTY, 'true') + GroovyPagesTemplateEngine strict = engineFor('strictTags=true\n') String template = '''<%@ page compileStatic="true" %>${g.link(controller: 'book')}''' when: - def t = gpte.createTemplate(template, 'ambiguous-not-unknown') + def t = strict.createTemplate(template, 'ambiguous-not-unknown') - then: 'a resolvable tag still compiles under strict checking' + then: 'a resolvable tag still compiles when the build declares its tags complete' t.metaInfo.compilationException == null - - cleanup: - System.clearProperty(GroovyPageTypeCheckingExtension.STRICT_TAG_CHECKING_PROPERTY) } void 'a namespace with no compiled tag library still resolves dynamically'() { From 07a909cdea4f9edec8a080a682bc417ffc0c5bb2 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 11 Aug 2026 13:00:56 -0600 Subject: [PATCH 34/74] Let the build state what it knows about its tag libraries Strictness was a JVM system property, where every other setting governing how pages compile is read from the build. It is declared in the grails extension instead, alongside the namespaces an application fills in while it runs, and reaches the compiler as a resource written next to the index: a declared input, so changing either recompiles what depends on it, and not packaged, since it says how this project compiles rather than what its tag libraries declare. The index is also put on the page compilation classpath, without which a page could not resolve a tag its own project declares - the source set's class directories do not carry it, and waiting for processResources would mean waiting for the compilation it exists to precede. It is generated in one process with the Java the project is built with, rather than one process per source directory, only the first of which cleared what the last had written. --- .../core/GrailsCompileStaticOptions.groovy | 41 +++++++++ .../gsp/GenerateTagLibraryIndexTask.groovy | 58 +++++++++---- .../plugin/views/gsp/GroovyPagePlugin.groovy | 64 +++++++++++--- .../views/gsp/TagLibraryIndexFiles.groovy | 84 +++++++++++++++++++ .../GenerateTagLibraryIndexTaskSpec.groovy | 80 +++++++++++++++++- .../index/TagLibraryIndexGenerator.java | 53 +++++++----- .../index/TagLibraryIndexGeneratorSpec.groovy | 24 ++++-- 7 files changed, 348 insertions(+), 56 deletions(-) create mode 100644 grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/views/gsp/TagLibraryIndexFiles.groovy 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..26d262342e1 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,51 @@ 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 + */ + 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.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 index ff6341e45b9..52888f6b63d 100644 --- 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 @@ -32,10 +32,12 @@ 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.SkipWhenEmpty import org.gradle.api.tasks.TaskAction +import org.gradle.jvm.toolchain.JavaLauncher import org.gradle.process.ExecOperations import org.gradle.process.JavaExecSpec @@ -77,7 +79,6 @@ abstract class GenerateTagLibraryIndexTask extends DefaultTask { * is compiled afterwards. */ @InputFiles - @SkipWhenEmpty @IgnoreEmptyDirectories @PathSensitive(PathSensitivity.RELATIVE) abstract ConfigurableFileCollection getSourceDirectories() @@ -109,28 +110,55 @@ abstract class GenerateTagLibraryIndexTask extends DefaultTask { @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() + + /** + * 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() }) - if (!directories) { - return - } - directories.eachWithIndex { File source, int position -> + if (directories) { + List arguments = [ + destination.canonicalPath, + String.valueOf(parameterNamesRetained.getOrElse(true)), + sourceEncoding.getOrElse('UTF-8') + ] + arguments.addAll(directories.collect { File source -> source.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 - spec.args( - source.canonicalPath, - destination.canonicalPath, - String.valueOf(parameterNamesRetained.getOrElse(true)), - sourceEncoding.getOrElse('UTF-8'), - // Only the first pass clears what was written before, so that several source - // directories contribute to one index rather than each erasing the last. - String.valueOf(position == 0) - ) + if (javaLauncher.present) { + spec.executable = javaLauncher.get().executablePath.asFile.absolutePath + } + spec.args(arguments) }.assertNormalExitValue() } + else { + TagLibraryIndexFiles.clearIndex(destination) + } + TagLibraryIndexFiles.writeSettings(destination, strictTags.getOrElse(false), + dynamicTagNamespaces.getOrElse([] as Set)) } } 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 46cb65a0ea8..de187baef52 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 @@ -35,6 +35,7 @@ import org.gradle.api.tasks.SourceSetOutput import org.gradle.api.tasks.TaskContainer import org.gradle.api.tasks.bundling.Jar import org.gradle.api.tasks.bundling.War +import org.gradle.language.jvm.tasks.ProcessResources import org.gradle.jvm.toolchain.JavaLauncher import org.gradle.jvm.toolchain.JavaToolchainService @@ -73,6 +74,33 @@ class GroovyPagePlugin implements Plugin { } } + /** + * 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 + } + } + + /** + * 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) + } + } + private void configureProject(Project project) { TaskContainer tasks = project.tasks @@ -83,14 +111,6 @@ class GroovyPagePlugin implements Plugin { Provider webappDestDir = project.layout.buildDirectory.dir('gsp-classes/webapp') output?.dir('gsp-classes') - FileCollection allClasspath = project.getObjects().fileCollection().from( - [ - project.configurations.named('compileClasspath'), - classesDirs, - 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. @@ -108,17 +128,39 @@ class GroovyPagePlugin implements Plugin { it.destinationDirectory.set(tagLibIndexDir) it.generatorClasspath.from(project.configurations.named('compileClasspath')) it.parameterNamesRetained.set(resolvePreserveParameterNames(project)) + it.strictTags.set(resolveStrictTags(project)) + it.dynamicTagNamespaces.set(resolveDynamicTagNamespaces(project)) + it.javaLauncher.convention(launcher) } + FileCollection tagLibIndex = project.files(tagLibIndexDir).builtBy(generateTagLibraryIndex) + + // Pages resolve tag calls against the index and are compiled in a process of their own, so the + // index has to be on their classpath. The source set's class directories do not carry it: it + // is a resource, and waiting for processResources would mean waiting for the compilation this + // is meant to precede. + FileCollection allClasspath = project.getObjects().fileCollection().from( + [ + project.configurations.named('compileClasspath'), + classesDirs, + tagLibIndex, + project.configurations.findByName('providedCompile') ?: null + ].findAll { it } + ) + mainSourceSet?.resources?.srcDir(tagLibIndexDir) - tasks.named('processResources').configure { it.dependsOn(generateTagLibraryIndex) } + tasks.named('processResources', ProcessResources).configure { ProcessResources processResources -> + processResources.dependsOn(generateTagLibraryIndex) + // The settings say how this project is compiled, not what its tag libraries declare, so + // they stay out of the artifact: a consumer must not inherit them. + processResources.exclude("${TagLibraryIndexFiles.INDEX_LOCATION}/${TagLibraryIndexFiles.SETTINGS_FILE}") + } // 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. tasks.named('compileGroovy', GroovyCompile).configure { GroovyCompile compile -> - compile.dependsOn(generateTagLibraryIndex) - compile.classpath = compile.classpath.plus(project.files(tagLibIndexDir)) + compile.classpath = compile.classpath.plus(tagLibIndex) } def compileGroovyPages = tasks.register('compileGroovyPages', GroovyPageForkCompileTask) { 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..943b1fdecce --- /dev/null +++ b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/views/gsp/TagLibraryIndexFiles.groovy @@ -0,0 +1,84 @@ +/* + * 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, matching + * {@code TagLibraryIndex.INDEX_LOCATION}. + */ + static final String INDEX_LOCATION = 'META-INF/grails/taglibs' + + /** + * Where the settings for this compilation are written, matching + * {@code TagLibraryIndex.SETTINGS_LOCATION}. + */ + static final String SETTINGS_FILE = 'compile-settings.properties' + + private TagLibraryIndexFiles() { + } + + /** + * 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 + */ + static void writeSettings(File destination, boolean strictTags, Set dynamicNamespaces) { + 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 = "dynamicTagNamespaces=${new TreeSet(dynamicNamespaces).join(',')}\n" + + "strictTags=${strictTags}\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 index a0766021c03..9189b017dec 100644 --- 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 @@ -28,6 +28,8 @@ 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 @@ -41,8 +43,9 @@ class GenerateTagLibraryIndexTaskSpec extends Specification { Project project def setup() { - // A tag library has to be present for the task to have any input: it is skipped when the - // source directory is absent or empty, which is what a project with no tag libraries wants. + // 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 = ''' @@ -111,6 +114,79 @@ class GenerateTagLibraryIndexTaskSpec extends Specification { dependencyNames(project.tasks.getByName('processResources')).contains('generateTagLibraryIndex') } + void 'compiling pages sees the index this project generates'() { + given: 'a page calling a tag the same project declares can only resolve it from the index' + Task compilePages = project.tasks.getByName('compileGroovyPages') + + expect: 'on the classpath itself, not merely produced before it' + compilePages.classpath.files*.canonicalFile.contains( + new File(projectDir.toFile(), 'build/generated/grails-taglibs').canonicalFile) + } + + void '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' + Task processResources = project.tasks.getByName('processResources') + + expect: + processResources.excludes.contains('META-INF/grails/taglibs/compile-settings.properties') + } + + 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: + File settings = new File(emptyDir, + 'build/generated/grails-taglibs/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 } 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 index 8084e67a50f..8c9d83bdbc2 100644 --- 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 @@ -23,6 +23,7 @@ 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.List; import java.util.stream.Stream; @@ -61,20 +62,22 @@ private TagLibraryIndexGenerator() { } /** - * @param args source directory, output directory, whether parameter names are retained, the - * source encoding, and whether to discard an index already present + * @param args the output directory, whether parameter names are retained, the source encoding, + * and then every source directory to describe */ public static void main(String[] args) throws IOException { - if (args.length < 2) { + if (args.length < 4) { throw new IllegalArgumentException( - "Usage: [parameterNamesRetained] [sourceEncoding]"); + "Usage: ..."); } - File sourceDir = new File(args[0]); - File outputDir = new File(args[1]); - boolean parameterNamesRetained = args.length < 3 || Boolean.parseBoolean(args[2]); - String encoding = args.length > 3 && !args[3].isEmpty() ? args[3] : "UTF-8"; - boolean clearExisting = args.length < 5 || Boolean.parseBoolean(args[4]); - generate(sourceDir, outputDir, parameterNamesRetained, encoding, clearExisting); + File outputDir = new File(args[0]); + boolean parameterNamesRetained = Boolean.parseBoolean(args[1]); + String encoding = args[2].isEmpty() ? "UTF-8" : args[2]; + List sourceDirs = new ArrayList<>(args.length - 3); + for (int i = 3; i < args.length; i++) { + sourceDirs.add(new File(args[i])); + } + generate(sourceDirs, outputDir, parameterNamesRetained, encoding); } /** @@ -88,29 +91,33 @@ public static void main(String[] args) throws IOException { */ public static void generate(File sourceDir, File outputDir, boolean parameterNamesRetained, String encoding) throws IOException { - generate(sourceDir, outputDir, parameterNamesRetained, encoding, true); + generate(Collections.singletonList(sourceDir), outputDir, parameterNamesRetained, encoding); } /** - * Regenerates the index, optionally adding to what is already there. + * Regenerates the index describing every tag library under any of several source directories. * - * @param sourceDir the directory to scan for tag libraries + *

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 outputDir the directory the index is written beneath * @param parameterNamesRetained whether the compilation writes parameter names into class files * @param encoding the source encoding - * @param clearExisting whether to discard an index already present, which several source - * directories contributing to one index must do only on the first of them * @throws IOException if the index cannot be written */ - public static void generate(File sourceDir, File outputDir, boolean parameterNamesRetained, - String encoding, boolean clearExisting) throws IOException { - if (clearExisting) { - TagLibraryIndexWriter.clear(outputDir); - } - if (sourceDir == null || !sourceDir.isDirectory()) { - return; + public static void generate(List sourceDirs, 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)); + } + } } - List sources = findGroovySources(sourceDir); if (sources.isEmpty()) { return; } 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 index 375ab3ef509..d08f4544e0f 100644 --- 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 @@ -151,7 +151,7 @@ class TagLibraryIndexGeneratorSpec extends Specification { descriptorFile('FineTagLib').exists() descriptor('FineTagLib').tags == 'present:METHOD' - and: 'the one that does not is left out, to be described when it is compiled' + and: 'the one that does not is left out here, and describes itself when it is compiled' !descriptorFile('UnresolvableTagLib').exists() manifest() == ['FineTagLib'] } @@ -167,15 +167,14 @@ class TagLibraryIndexGeneratorSpec extends Specification { manifest().isEmpty() } - void 'a second source directory adds to the index rather than replacing it'() { + 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: 'the first pass clears and the second adds' - TagLibraryIndexGenerator.generate(sources.toFile(), output.toFile(), true, 'UTF-8', true) - TagLibraryIndexGenerator.generate(other.toFile(), output.toFile(), true, 'UTF-8', false) + when: + TagLibraryIndexGenerator.generate([sources.toFile(), other.toFile()], output.toFile(), true, 'UTF-8') then: 'both are described' manifest() == ['FirstTagLib', 'SecondTagLib'] @@ -183,6 +182,21 @@ class TagLibraryIndexGeneratorSpec extends Specification { 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 } From fcd70e89bf0a9dac0cee288abfe3471dd7e098b7 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 11 Aug 2026 13:00:56 -0600 Subject: [PATCH 35/74] Document compiled tag resolution Covers what is resolved and what is left to dispatch, the precedence an unqualified call follows, why a page is only resolved where it declares compileStatic, the strictTags and dynamicTagNamespaces settings and what each is for, and which tag libraries the build describes and which describe themselves. --- .../src/en/guide/introduction/whatsNew.adoc | 29 +++- .../theWebLayer/gsp/taglibs/compiledTags.adoc | 140 ++++++++++++++---- .../src/en/guide/upgrading/upgrading80x.adoc | 48 ++++-- 3 files changed, 164 insertions(+), 53 deletions(-) diff --git a/grails-doc/src/en/guide/introduction/whatsNew.adoc b/grails-doc/src/en/guide/introduction/whatsNew.adoc index 3efe94b4eb0..791a3624ca3 100644 --- a/grails-doc/src/en/guide/introduction/whatsNew.adoc +++ b/grails-doc/src/en/guide/introduction/whatsNew.adoc @@ -295,14 +295,29 @@ class BookController { } ---- -A tag that no compiled tag library declares is reported as a compilation warning, which -`-Dgrails.views.gsp.strictTagChecking=true` turns into an error. Calls that cannot be resolved when -compiled — attributes assembled at runtime, a namespace no compiled tag library declares, a tag -declared by more than one of them, a tag defined as a closure, or a name something else in scope -answers to — are dispatched exactly as before. Expressions in a GSP page are checked the same way but -are not rewritten; a page still selects the tag by name as it renders, without touching a metaclass. +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 has no signature to resolve against, so calls to such a tag stay dynamic. Define tags as +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 index 64d38bff977..7e7fea30d14 100644 --- a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc +++ b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc @@ -55,9 +55,10 @@ Closure hello = { Map attrs -> } ---- -A closure has no signature to resolve, so a call to a tag defined this way cannot be resolved when the -calling code is compiled and is dispatched dynamically instead. Compiling a tag library that declares -one produces a warning naming the tag. +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. Compiling a tag +library that declares one produces a warning naming the tag. ==== Calling tags @@ -74,18 +75,23 @@ class BookController { } ---- -This applies where the call is written with literal attributes, a literal body, both or neither. A -call whose attributes are assembled at runtime is dispatched as before: +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) // dispatched dynamically +g.createLink(attrs) // still compiled into an invocation ---- -So is a call into a namespace no compiled tag library declares, which is what allows a tag library -registered while an application is running to keep working, and so is a call to a tag defined as a -`Closure` field, which carries no signature to bind to. +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 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: @@ -98,33 +104,90 @@ def index() { } ---- -Only a call naming its namespace is rewritten. An unqualified call, as `message(code: 'x')` is, is -dispatched as before, because whether such a name is a tag or a method of the calling class is decided -where it is called rather than by the tag libraries on the classpath. +A call written without a namespace follows the same rule. It 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 — +and is then offered to the calling tag library's own namespace before the default one, which is the +order dispatch uses at runtime: + +[source,groovy] +---- +class BookController { + def index() { + String markup = createLink(controller: 'book') // compiled into an invocation + } +} +---- + +Tags called from within a closure — a tag body, a `withFormat` block, anything taking a block — are +compiled the same way as tags called directly. + +==== 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. -Expressions in a GSP page are checked against the same descriptions when the page is compiled, so a -misspelled tag is reported there too. They are not rewritten: a page reaches its tags through the -namespace dispatcher, which resolves the tag by name at render time. It does so without installing -anything onto a metaclass, but the tag is selected when the page runs rather than when it compiles. +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. Both forms are checked against the same descriptions in +every page, so a misspelling is reported whether or not the page is compiled statically. ==== Reporting unknown tags -By default, a tag that no compiled tag library declares is reported as a compilation warning. It is a -warning rather than an error because a namespace can hold tag libraries that were not compiled with a -description: a plugin built against an earlier version of Grails contributes tags without one, and a -tag library registered at runtime contributes more. A tag missing from the description is therefore -not necessarily a misspelling. +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. -Set the following system property when building to turn that warning into a compilation error: +An application whose tag libraries are all described can ask for an error instead: -[source,bash] +[source,groovy] +.build.gradle ---- --Dgrails.views.gsp.strictTagChecking=true +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. -A tag declared by more than one tag library is never reported. Which one runs depends on the order the -tag libraries are registered, which is not known when the calling code is compiled, so such a call is -left to be resolved at runtime. +`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. ==== Where the description lives @@ -132,7 +195,22 @@ Each tag library contributes one file under `META-INF/grails/taglibs` in the art 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. -For an application's own tag libraries, the `generateTagLibraryIndex` task writes the description from -the sources under `grails-app/taglib` before compilation, so tags an application declares are -resolvable in the same compilation that defines them. Tag libraries elsewhere on the source path are -described as they are compiled, which makes them resolvable to anything compiled afterwards. +One thing writes that description per build. Under the Grails Gradle plugin it is the +`generateTagLibraryIndex` task, which reads the sources under `grails-app/taglib` before compilation — +so tags an application declares are resolvable in the same compilation that defines them, and a tag +library that is renamed or deleted disappears from the index. A project keeping tag libraries +elsewhere adds those directories to the task: + +[source,groovy] +.build.gradle +---- +generateTagLibraryIndex { + sourceDirectories.from(file('src/main/groovy')) +} +---- + +A tag library nothing has described that way describes itself as it is compiled instead, which makes +it resolvable to anything compiled after it. That covers a plain Groovy compilation, a build that does +not apply the Grails Gradle plugin, a tag library in a directory the task was not given, and one the +task could not read ahead of compilation — a tag library referring to a class of the same project +cannot be resolved before that project is built, and is described when it is compiled instead. diff --git a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc index 0cc3163c7e1..992f08c7aca 100644 --- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc +++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc @@ -2259,21 +2259,39 @@ the previous set of beans. === 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. Existing applications need no change: a tag that -cannot be resolved when compiled is dispatched exactly as before, which covers tag libraries from -plugins built against earlier versions of Grails, tag libraries registered while an application runs, -and calls whose attributes are assembled at runtime. - -Two things are worth knowing when upgrading. - -A tag that no compiled tag library declares produces a compilation warning. Building with -`-Dgrails.views.gsp.strictTagChecking=true` turns those warnings into errors, 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. - -Tags defined as `Closure` fields now warn at compile time. They still work, but a closure carries no -signature, so calls to such a tag cannot be resolved when the calling code is compiled and stay -dynamic. Convert them to methods: +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 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. 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 now reaches the tag instead. Declare the method on the class, name the namespace +in `dynamicTagNamespaces`, or call the tag with its namespace, if both exist. + +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 `Closure` fields 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. Convert +them to methods: [source,groovy] ---- From 33e2e90b9f3127f4728ea7e66c09180a50d3b614 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 11 Aug 2026 13:18:53 -0600 Subject: [PATCH 36/74] Know a tag library declaring no tags has been described Whether a tag library had already been described was decided from the tags read for it, so one declaring none looked undescribed and was described a second time by the compiler, putting a duplicate descriptor and a competing manifest into the class output. It is recorded from the descriptor itself. --- .../grails/taglib/index/TagLibraryIndex.java | 4 +++ .../taglib/index/TagLibraryIndexSpec.groovy | 35 +++++++++++++++++++ 2 files changed, 39 insertions(+) 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 index d0554ff0426..7a0caeeb8bc 100644 --- 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 @@ -151,6 +151,10 @@ public static TagLibraryIndex load(ClassLoader classLoader) { 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(",")) { 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 index 8b9ebf7de8d..55863f191f6 100644 --- 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 @@ -214,6 +214,41 @@ class TagLibraryIndexSpec extends Specification { 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']])) From fdeb5e86fe4d60dc789dc8263b4a6e5e202fcc06 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 11 Aug 2026 13:18:53 -0600 Subject: [PATCH 37/74] Say which page tag forms strict checking covers The guide said both forms were checked in every page, which the boundary the previous change drew makes untrue of expressions: an expression is checked only where it is resolved, in a page declaring compileStatic, because elsewhere the receiver may come from the model. Markup is unambiguously a tag whatever the page does and is checked everywhere. --- .../src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc index 7e7fea30d14..07993da8f64 100644 --- a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc +++ b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc @@ -148,8 +148,9 @@ ${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. Both forms are checked against the same descriptions in -every page, so a misspelling is reported whether or not the page is compiled statically. +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 From 2a72625dfcaa0ac991f1543029e1275e49d41e66 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 11 Aug 2026 13:40:47 -0600 Subject: [PATCH 38/74] Do not fork the index generator for a project with no tag libraries Dropping SkipWhenEmpty, so that a build's settings are recorded whether or not a project declares tag libraries, made the task fork a process whenever grails-app/taglib merely existed. A project with no tag libraries has no reason to carry the generator on its compile classpath, so the fork failed the build outright. Adds the functional test that found it, which also holds the plugin to where the index is wired: on the classpath of this project's own compilation, on the classpath of its pages, and travelling with the artifact. --- .../gsp/GenerateTagLibraryIndexTask.groovy | 5 +- ...TagLibraryIndexWiringFunctionalSpec.groovy | 59 +++++++++++++++++++ .../taglib-index-wiring/build.gradle | 23 ++++++++ .../taglib-index-wiring/settings.gradle | 1 + 4 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/views/gsp/TagLibraryIndexWiringFunctionalSpec.groovy create mode 100644 grails-gradle/plugins/src/test/resources/test-projects/taglib-index-wiring/build.gradle create mode 100644 grails-gradle/plugins/src/test/resources/test-projects/taglib-index-wiring/settings.gradle 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 index 52888f6b63d..0e650599636 100644 --- 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 @@ -38,6 +38,7 @@ 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 @@ -137,7 +138,9 @@ abstract class GenerateTagLibraryIndexTask extends DefaultTask { File destination = destinationDirectory.get().asFile destination.mkdirs() List directories = new ArrayList(sourceDirectories.files.findAll { File dir -> dir.isDirectory() }) - if (directories) { + // 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 arguments = [ destination.canonicalPath, String.valueOf(parameterNamesRetained.getOrElse(true)), 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..9eaa82d994a --- /dev/null +++ b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/views/gsp/TagLibraryIndexWiringFunctionalSpec.groovy @@ -0,0 +1,59 @@ +/* + * 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 is on the classpath of everything that resolves tag calls against it"() { + given: + 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_INDEX=true') + + and: 'so does a page of this project, compiled in a process of its own' + result.output.contains('PAGES_SEE_INDEX=true') + + and: 'and it travels with the artifact, so a project depending on this one resolves them too' + result.output.contains('INDEX_IS_A_RESOURCE=true') + } + + 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: + def result = executeTask('processResources') + + then: 'so forking it here would fail the build of a project with nothing to describe' + assertTaskSuccess('processResources', 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..098690c4cfe --- /dev/null +++ b/grails-gradle/plugins/src/test/resources/test-projects/taglib-index-wiring/build.gradle @@ -0,0 +1,23 @@ +// Verifies where the tag library index is written and what it is wired into. No tag library sources +// are present, which is itself the case under test: a project with none must not fork the generator, +// and so must not need it on a classpath it has no reason to have. +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) } + + doLast { + println "COMPILE_SEES_INDEX=${compileGroovyClasspath.any { it.endsWith('/generated/grails-taglibs') }}" + println "PAGES_SEE_INDEX=${pagesClasspath.any { it.endsWith('/generated/grails-taglibs') }}" + println "INDEX_IS_A_RESOURCE=${resourceDirs.any { it.endsWith('/generated/grails-taglibs') }}" + } +} + +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' From c417954b9a27d94d656d548d891f8086fe4b2a9b Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 11 Aug 2026 14:05:28 -0600 Subject: [PATCH 39/74] Measure what compiling a tag call is worth on its own The only figure this work had was end to end, and covered removing the metaclass writes and compiling calls into invocations together, so what either was worth separately was unknown. That matters now that a page is only resolved where it declares compileStatic: an application that has not enabled it gets nothing from the page side, and what is left is the calls written in its tag libraries and controllers. Both sides of each comparison run against this branch, so the metaclass writes are already gone from both, and only whether a call was compiled into an invocation differs - which a build can still turn off per namespace. Pages are compiled once and rendered repeatedly, since compiling them per render measures the compiler instead. Off unless GRAILS_TAGLIB_BENCH is set: a timing run is evidence, not a pass or fail, and is no use running alongside other tests. --- .../taglib/TagDispatchBenchmarkSpec.groovy | 212 ++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/TagDispatchBenchmarkSpec.groovy diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/TagDispatchBenchmarkSpec.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/TagDispatchBenchmarkSpec.groovy new file mode 100644 index 00000000000..4c4da71043a --- /dev/null +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/TagDispatchBenchmarkSpec.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 grails.testing.web.taglib.TagLibUnitTest +import groovy.text.Template +import org.grails.gsp.GroovyPagesTemplateEngine +import org.grails.taglib.index.TagLibraryIndex +import org.grails.plugins.web.taglib.ApplicationTagLib +import spock.lang.Requires +import spock.lang.Shared +import spock.lang.Specification + +/** + * What compiling a tag call into an invocation is worth, separately from removing the metaclass work + * that used to surround every call. + * + *

Both are measured against the same framework, so the metaclass writes are already gone from both + * sides. What varies is only whether a call was compiled into an invocation, which is what a build can + * still turn off per namespace. That isolates the part of the change whose value was never measured + * on its own. + * + *

Off unless asked for, since a timing run is neither quick nor a pass/fail assertion: + * + *

+ * GRAILS_TAGLIB_BENCH=true ./gradlew :grails-gsp:test \
+ *     --tests '*TagDispatchBenchmarkSpec' --rerun-tasks -i
+ * 
+ * + *

Gated on the environment rather than a system property because a forked test process inherits + * the environment, where this build bridges only a few named properties into it. + */ +@Requires({ System.getenv('GRAILS_TAGLIB_BENCH') }) +class TagDispatchBenchmarkSpec extends Specification implements TagLibUnitTest { + + /** + * Tag calls per render. Fewer than the 400-call page the pull request measured, because that many + * expressions in one page exceed the size a single Groovy method may compile to. Results are + * reported per call, so the count only has to be large enough to dominate per-render overhead. + */ + private static final int CALLS_PER_RENDER = 50 + + private static final int WARMUP_RENDERS = intFromEnv('GRAILS_TAGLIB_BENCH_WARMUP', 300) + private static final int MEASURED_RENDERS = intFromEnv('GRAILS_TAGLIB_BENCH_RENDERS', 2000) + /** Alternated rather than run end to end, so a machine warming up cannot favour one side. */ + private static final int ROUNDS = intFromEnv('GRAILS_TAGLIB_BENCH_ROUNDS', 7) + + void 'a page renders its tags faster once they are compiled into invocations'() { + given: 'each page compiled once, so what is timed is rendering rather than compiling' + GroovyPagesTemplateEngine engine = applicationContext.getBean(GroovyPagesTemplateEngine) + Template dispatched = engine.createTemplate(page(false), 'benchDispatched') + Template compiled = engine.createTemplate(page(true), 'benchCompiled') + + when: + List dynamicRuns = [] + List staticRuns = [] + WARMUP_RENDERS.times { + renderOnce(dispatched) + renderOnce(compiled) + } + ROUNDS.times { + dynamicRuns << timePerCall(dispatched) + staticRuns << timePerCall(compiled) + } + + then: + report('dispatched', dynamicRuns) + report('compiled', staticRuns) + double dynamicMedian = median(dynamicRuns) + double staticMedian = median(staticRuns) + println String.format(' change %+.1f%% per tag call', + ((staticMedian - dynamicMedian) / dynamicMedian) * 100.0d) + println " (${CALLS_PER_RENDER} calls per render, ${MEASURED_RENDERS} renders per round, " + + "${ROUNDS} alternated rounds)" + + and: 'reported rather than asserted: a timing is evidence, not a contract' + dynamicMedian > 0.0d && staticMedian > 0.0d + } + + void 'a tag library calls other tags faster once those calls are compiled into invocations'() { + given: 'two tag libraries alike but for whether the build let their calls be compiled' + Class dispatchedTagLib = compileCaller('BenchDispatchedTagLib', 'benchdispatched', true) + Class compiledTagLib = compileCaller('BenchCompiledTagLib', 'benchcompiled', false) + mockTagLib(dispatchedTagLib) + mockTagLib(compiledTagLib) + + and: 'each reached through a page compiled once, so only the tag calls within differ' + GroovyPagesTemplateEngine engine = applicationContext.getBean(GroovyPagesTemplateEngine) + Template dispatched = engine.createTemplate('', 'benchCallerDispatched') + Template compiled = engine.createTemplate('', 'benchCallerCompiled') + + when: + List dispatchedRuns = [] + List compiledRuns = [] + WARMUP_RENDERS.times { + renderOnce(dispatched) + renderOnce(compiled) + } + ROUNDS.times { + dispatchedRuns << timePerCall(dispatched) + compiledRuns << timePerCall(compiled) + } + + then: + println ' -- calls written inside a tag library --' + report('dispatched', dispatchedRuns) + report('compiled', compiledRuns) + double dispatchedMedian = median(dispatchedRuns) + double compiledMedian = median(compiledRuns) + println String.format(' change %+.1f%% per tag call', + ((compiledMedian - dispatchedMedian) / dispatchedMedian) * 100.0d) + + and: + dispatchedMedian > 0.0d && compiledMedian > 0.0d + } + + /** + * Compiles a tag library whose tag calls the framework's own tag library, either left to dispatch + * or compiled into invocations depending on what the build declared. + * + * @param declaredDynamic whether the build declared the called namespace as filled in at runtime, + * which is what turns compile-time resolution off for it + */ + private Class compileCaller(String className, String namespace, boolean declaredDynamic) { + StringBuilder body = new StringBuilder() + CALLS_PER_RENDER.times { int i -> + body.append(" out << g.createLink(controller: 'book', action: 'show', id: ${i})\n") + } + String source = """ + import grails.gsp.TagLib + @TagLib + class ${className} { + static namespace = '${namespace}' + def callsTags(Map attrs) { +${body} + } + } + """ + ClassLoader parent = getClass().classLoader + if (declaredDynamic) { + Path settings = Files.createTempDirectory(className) + Path indexDir = Files.createDirectories(settings.resolve(TagLibraryIndex.INDEX_LOCATION)) + indexDir.resolve('compile-settings.properties').toFile().text = 'dynamicTagNamespaces=g\n' + parent = new URLClassLoader([settings.toUri().toURL()] as URL[], parent) + } + new GroovyClassLoader(parent).parseClass(source, className + '.groovy') + } + + private double timePerCall(Template template) { + long start = System.nanoTime() + MEASURED_RENDERS.times { + renderOnce(template) + } + long elapsed = System.nanoTime() - start + elapsed / (double) (MEASURED_RENDERS * CALLS_PER_RENDER) + } + + private static void renderOnce(Template template) { + StringWriter out = new StringWriter() + template.make().writeTo(out) + } + + private static void report(String label, List runs) { + println String.format(' %-16s median %7.1f ns/call min %7.1f max %7.1f', + label, median(runs), runs.min(), runs.max()) + } + + private static int intFromEnv(String name, int fallback) { + String value = System.getenv(name) + value ? value as int : fallback + } + + private static double median(List values) { + List sorted = values.sort(false) + int middle = (sorted.size() / 2) as int + sorted.size() % 2 == 1 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2.0d + } + + /** + * @param compileStatic whether the page gives up dynamic resolution, which is what allows its tag + * expressions to be compiled into invocations + */ + private static String page(boolean compileStatic) { + StringBuilder markup = new StringBuilder() + if (compileStatic) { + markup.append('<%@ page compileStatic="true" %>') + } + CALLS_PER_RENDER.times { int i -> + markup.append("\${g.createLink(controller: 'book', action: 'show', id: ${i})}") + } + markup.toString() + } +} From 73d6bd3fdfc763be01f056575a620bf27579d778 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 11 Aug 2026 14:44:53 -0600 Subject: [PATCH 40/74] Describe a tag library that refers to what this project declares 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 when the index is generated before compilation. Such a tag library could not be read, so it was skipped and left to describe itself as it compiled, into the class output, where a second index competed with this one for the same path when packaged and nothing removed it once the tag library was renamed or deleted: an incremental compilation does not revisit a source that has not changed. The generator now resolves a type this project declares to its own source and compiles it alongside, which is what the compiler itself does for types within one compilation. Nothing is skipped for that reason, so the build describes every tag library and writes the index in one place, which it rewrites in full each time. Deliberately not answered with a stand-in class node. What is missing is exactly what decides the description: a base class carries the namespace, so a stand-in would file the tag library under g; a trait carries tags, which would then be absent; and a parameter type decides whether a method is a tag at all, which runtime asks by assignability. A resolver sees a name and not the context it appears in, so it cannot tell which of those it is being asked about. It would also answer for the first candidate a star import offers, before the real one was tried, and would invent a misspelled type rather than let it fail. Each of those makes the index disagree with what the application does, which is the one thing it must never do. A type not found in source is left unresolved, and the tag library referring to it is skipped as before. --- .../theWebLayer/gsp/taglibs/compiledTags.adoc | 14 +- .../gsp/GenerateTagLibraryIndexTask.groovy | 17 +- .../plugin/views/gsp/GroovyPagePlugin.groovy | 12 + .../index/TagLibraryIndexGenerator.java | 113 ++++++-- .../TagLibArtefactTypeAstTransformation.java | 28 +- .../index/SingleIndexProducerSpec.groovy | 10 +- .../SourceResolvedIndexGeneratorSpec.groovy | 272 ++++++++++++++++++ 7 files changed, 421 insertions(+), 45 deletions(-) create mode 100644 grails-gsp/grails-web-taglib/src/test/groovy/org/grails/taglib/index/SourceResolvedIndexGeneratorSpec.groovy diff --git a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc index 07993da8f64..24aff4954ad 100644 --- a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc +++ b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc @@ -210,8 +210,12 @@ generateTagLibraryIndex { } ---- -A tag library nothing has described that way describes itself as it is compiled instead, which makes -it resolvable to anything compiled after it. That covers a plain Groovy compilation, a build that does -not apply the Grails Gradle plugin, a tag library in a directory the task was not given, and one the -task could not read ahead of compilation — a tag library referring to a class of the same project -cannot be resolved before that project is built, and is described when it is compiled instead. +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: the task reads that 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 — each tag library describes itself as it is compiled instead, which makes it resolvable +to anything compiled after it. 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 index 0e650599636..393b9f08616 100644 --- 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 @@ -84,6 +84,18 @@ abstract class GenerateTagLibraryIndexTask extends DefaultTask { @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. */ @@ -141,12 +153,15 @@ abstract class GenerateTagLibraryIndexTask extends DefaultTask { // 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') + 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 -> 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 de187baef52..f8f2a989b44 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 @@ -101,6 +101,15 @@ class GroovyPagePlugin implements Plugin { } } + /** + * 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) + } + private void configureProject(Project project) { TaskContainer tasks = project.tasks @@ -127,6 +136,9 @@ class GroovyPagePlugin implements Plugin { it.sourceDirectories.from(project.layout.projectDirectory.dir('grails-app/taglib')) it.destinationDirectory.set(tagLibIndexDir) 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) }) it.parameterNamesRetained.set(resolvePreserveParameterNames(project)) it.strictTags.set(resolveStrictTags(project)) it.dynamicTagNamespaces.set(resolveDynamicTagNamespaces(project)) 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 index 8c9d83bdbc2..be65f51d811 100644 --- 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 @@ -30,6 +30,7 @@ import org.codehaus.groovy.ast.AnnotationNode; import org.codehaus.groovy.ast.ClassNode; +import org.codehaus.groovy.control.ClassNodeResolver; import org.codehaus.groovy.control.CompilationUnit; import org.codehaus.groovy.control.CompilerConfiguration; import org.codehaus.groovy.control.Phases; @@ -62,22 +63,25 @@ private TagLibraryIndexGenerator() { } /** - * @param args the output directory, whether parameter names are retained, the source encoding, - * and then every source directory to describe + * @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: ..."); + 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]; - List sourceDirs = new ArrayList<>(args.length - 3); - for (int i = 3; i < args.length; i++) { - sourceDirs.add(new File(args[i])); + 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, outputDir, parameterNamesRetained, encoding); + generate(sourceDirs, resolutionRoots, outputDir, parameterNamesRetained, encoding); } /** @@ -91,7 +95,22 @@ public static void main(String[] args) throws IOException { */ public static void generate(File sourceDir, File outputDir, boolean parameterNamesRetained, String encoding) throws IOException { - generate(Collections.singletonList(sourceDir), outputDir, parameterNamesRetained, encoding); + 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); } /** @@ -102,13 +121,15 @@ public static void generate(File sourceDir, File outputDir, boolean parameterNam * 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, File outputDir, boolean parameterNamesRetained, - String encoding) throws IOException { + 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) { @@ -122,7 +143,8 @@ public static void generate(List sourceDirs, File outputDir, boolean param return; } - for (ClassNode classNode : parse(sources, parameterNamesRetained, encoding)) { + List roots = resolutionRoots != null ? resolutionRoots : Collections.emptyList(); + for (ClassNode classNode : parse(sources, roots, parameterNamesRetained, encoding)) { if (!isTagLibrary(classNode)) { continue; } @@ -147,17 +169,17 @@ public static void generate(List sourceDirs, File outputDir, boolean param * compiler as it is built, and until then its tags resolve dynamically, exactly as a tag library * with no descriptor does. */ - private static List parse(List sources, boolean parameterNamesRetained, - String encoding) { + private static List parse(List sources, List resolutionRoots, + boolean parameterNamesRetained, String encoding) { try { - return collectClassNodes(compile(sources, parameterNamesRetained, encoding)); + return collectClassNodes(compile(sources, resolutionRoots, parameterNamesRetained, encoding)); } catch (Exception wholeSourceSetFailed) { List classNodes = new ArrayList<>(); List skipped = new ArrayList<>(); for (File source : sources) { try { classNodes.addAll(collectClassNodes( - compile(List.of(source), parameterNamesRetained, encoding))); + compile(List.of(source), resolutionRoots, parameterNamesRetained, encoding))); } catch (Exception singleSourceFailed) { skipped.add(source.getName()); } @@ -171,12 +193,15 @@ private static List parse(List sources, boolean parameterNamesR } } - private static CompilationUnit compile(List sources, boolean parameterNamesRetained, - String encoding) { + 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); } @@ -186,6 +211,58 @@ private static CompilationUnit compile(List sources, boolean parameterName 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())); 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 684b3cb7bd5..224b79d543a 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 @@ -73,11 +73,12 @@ protected void writeIndexEntry(SourceUnit sourceUnit, ClassNode classNode) { // descriptor; those callers resolve tags at runtime. return; } - if (alreadyDescribed(sourceUnit, classNode)) { - // The build described this tag library ahead of compiling it. A second copy written into - // the class output would be merged with that one on the classpath and packaged alongside - // it, and being written class by class it would keep describing the tag library after it - // had been renamed or deleted. One thing describes each tag library. + 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); @@ -101,20 +102,15 @@ protected void writeIndexEntry(SourceUnit sourceUnit, ClassNode classNode) { } /** - * Whether something has already described this tag library, which the Grails Gradle plugin does - * ahead of compiling by generating the index from source. + * 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. * - *

Asked per tag library rather than per build, because generating the index ahead of - * compilation cannot always describe every one of them: a tag library referring to a class of the - * same project that has not been compiled yet is skipped there. Treating the build as - * authoritative for all of them would leave such a tag library described by nothing at all. + *

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 alreadyDescribed(SourceUnit sourceUnit, ClassNode classNode) { + private static boolean buildOwnsIndex(SourceUnit sourceUnit) { ClassLoader classLoader = sourceUnit.getClassLoader(); - if (classLoader == null) { - return false; - } - return TagLibraryIndex.forClassLoader(classLoader).isClassDescribed(classNode.getName()); + return classLoader != null && classLoader.getResource(TagLibraryIndex.SETTINGS_LOCATION) != null; } /** 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 index 8882f8ecf40..c83a8ae7ba2 100644 --- 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 @@ -55,7 +55,7 @@ class SingleIndexProducerSpec extends Specification { manifest(output).isFile() } - void 'a tag library the build already described writes no second descriptor'() { + 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) @@ -64,12 +64,12 @@ class SingleIndexProducerSpec extends Specification { !manifest(output).isFile() } - void 'a tag library the build could not describe still describes itself'() { - given: 'the build generated an index, but skipped this tag library, as an unresolvable one is' + 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: 'otherwise it would be described by nothing at all and vanish from the index' - descriptor(output, 'ProducerCheckTagLib').isFile() + 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) { 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..7f19502972b --- /dev/null +++ b/grails-gsp/grails-web-taglib/src/test/groovy/org/grails/taglib/index/SourceResolvedIndexGeneratorSpec.groovy @@ -0,0 +1,272 @@ +/* + * 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:METHOD' + } + + 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:METHOD', 'hello:METHOD'] + } + + 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:METHOD' + } + + 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:METHOD' + } + + 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'] + } + + 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 + } +} From 2834ffbf98ec8d279a50712e5fa15ca77790bce4 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 11 Aug 2026 15:22:51 -0600 Subject: [PATCH 41/74] Package the index written after this project is compiled The index was generated once, before compilation, and that one artifact had to serve two purposes it cannot both serve. Read from source, it cannot describe a tag library referring to a type written in another language or generated by the build, so packaging it handed a consumer a partial description, and compiling pages against it hid those tags from every page. A namespace missing some of its tags also made strict checking report calls to tags that do exist. There are two now, each with one job. The first is written before compilation and used only to compile this project, so a call to a tag the project declares still resolves as it compiles; it is neither packaged nor used to compile pages. What it could not read is recorded against the namespaces affected, and nothing in a namespace known to be incomplete is ever reported, so strict checking cannot fail a build over correct code. Where what was missed cannot even be attributed to a namespace, none is treated as complete. The second is written afterwards with this project's own classes on the classpath, where every tag library resolves. That one is authoritative: pages compile against it, it is packaged, and a project depending on this one reads it. Each run replaces the directory. It waits for the classes task rather than for the compile tasks, so that it also waits for anything else writing into the class output, and reaches the artifact and the runtime classpath directly rather than through processResources, which the classes task waits for. Resolving a type this project declares from its Groovy source stays, as what it is: a way to describe more before compilation, not the thing correctness rests on. --- .../theWebLayer/gsp/taglibs/compiledTags.adoc | 34 +++++--- .../plugin/views/gsp/GroovyPagePlugin.groovy | 59 ++++++++++--- .../GenerateTagLibraryIndexTaskSpec.groovy | 42 +++++---- ...TagLibraryIndexWiringFunctionalSpec.groovy | 61 ++++++++++--- .../taglib-index-wiring/build.gradle | 19 +++-- .../grails/taglib/index/TagLibraryIndex.java | 78 ++++++++++++++++- .../index/TagLibraryIndexGenerator.java | 64 ++++++++++++-- .../taglib/index/TagLibraryIndexWriter.java | 29 +++++++ .../compiler/CompiledTagCallRewriter.java | 6 ++ .../SourceResolvedIndexGeneratorSpec.groovy | 85 +++++++++++++++++++ 10 files changed, 411 insertions(+), 66 deletions(-) diff --git a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc index 24aff4954ad..1a45bf88f2c 100644 --- a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc +++ b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc @@ -196,25 +196,37 @@ Each tag library contributes one file under `META-INF/grails/taglibs` in the art 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. -One thing writes that description per build. Under the Grails Gradle plugin it is the -`generateTagLibraryIndex` task, which reads the sources under `grails-app/taglib` before compilation — -so tags an application declares are resolvable in the same compilation that defines them, and a tag -library that is renamed or deleted disappears from the index. A project keeping tag libraries -elsewhere adds those directories to the task: +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 to both tasks: [source,groovy] .build.gradle ---- -generateTagLibraryIndex { +tasks.matching { it.name in ['generateTagLibraryIndex', 'packageTagLibraryIndex'] }.configureEach { sourceDirectories.from(file('src/main/groovy')) } ---- -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: the task reads that 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. +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 — each tag library describes itself as it is compiled instead, which makes it resolvable 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 f8f2a989b44..f76badb5b63 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 @@ -127,10 +127,14 @@ class GroovyPagePlugin implements Plugin { JavaToolchainService toolchains = project.extensions.getByType(JavaToolchainService) Provider launcher = toolchains.launcherFor(javaExtension.toolchain) - // The index describes the tag libraries in this project and has to exist before anything that - // resolves tag calls against it is compiled. It is generated from source rather than from - // compiled classes, so its classpath is the compile classpath alone: adding this project's own - // output would make it wait for the compilation it is meant to precede. + // 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. Provider tagLibIndexDir = project.layout.buildDirectory.dir('generated/grails-taglibs') def generateTagLibraryIndex = tasks.register('generateTagLibraryIndex', GenerateTagLibraryIndexTask) { it.sourceDirectories.from(project.layout.projectDirectory.dir('grails-app/taglib')) @@ -146,25 +150,43 @@ class GroovyPagePlugin implements Plugin { } 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') + def packageTagLibraryIndex = tasks.register('packageTagLibraryIndex', GenerateTagLibraryIndexTask) { + it.description = 'Regenerates the tag library index against the compiled project' + it.sourceDirectories.from(project.layout.projectDirectory.dir('grails-app/taglib')) + it.destinationDirectory.set(packagedIndexDir) + // The whole output, which is built by classes, so this waits for everything that writes + // into it rather than for the compile tasks alone. + it.generatorClasspath.from(project.configurations.named('compileClasspath'), output) + it.parameterNamesRetained.set(resolvePreserveParameterNames(project)) + it.strictTags.set(resolveStrictTags(project)) + it.dynamicTagNamespaces.set(resolveDynamicTagNamespaces(project)) + it.javaLauncher.convention(launcher) + } + 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 - // index has to be on their classpath. The source set's class directories do not carry it: it - // is a resource, and waiting for processResources would mean waiting for the compilation this - // is meant to precede. + // authoritative index has to be on their classpath. FileCollection allClasspath = project.getObjects().fileCollection().from( [ project.configurations.named('compileClasspath'), classesDirs, - tagLibIndex, + packagedTagLibIndex, project.configurations.findByName('providedCompile') ?: null ].findAll { it } ) - mainSourceSet?.resources?.srcDir(tagLibIndexDir) - tasks.named('processResources', ProcessResources).configure { ProcessResources processResources -> - processResources.dependsOn(generateTagLibraryIndex) - // The settings say how this project is compiled, not what its tag libraries declare, so - // they stay out of the artifact: a consumer must not inherit them. - processResources.exclude("${TagLibraryIndexFiles.INDEX_LOCATION}/${TagLibraryIndexFiles.SETTINGS_FILE}") + // 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) } // Compiling this project's own controllers and tag libraries has to see the index too, or a @@ -175,6 +197,15 @@ class GroovyPagePlugin implements Plugin { compile.classpath = compile.classpath.plus(tagLibIndex) } + String settingsPath = "${TagLibraryIndexFiles.INDEX_LOCATION}/${TagLibraryIndexFiles.SETTINGS_FILE}" + tasks.withType(Jar).configureEach { Jar archive -> + archive.from(packagedTagLibIndex) { CopySpec spec -> + // The settings say how this project is compiled, not what its tag libraries declare, + // so they stay out of the artifact: a consumer must not inherit them. + spec.exclude(settingsPath) + } + } + def compileGroovyPages = tasks.register('compileGroovyPages', GroovyPageForkCompileTask) { it.destinationDirectory.set(destDir) it.tmpDirPath = getTmpDirPath(project) 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 index 9189b017dec..389e8c1137d 100644 --- 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 @@ -102,33 +102,43 @@ class GenerateTagLibraryIndexTaskSpec extends Specification { !dependencyNames(generate).contains('compileGroovy') } - void 'the index is packaged as a resource'() { - given: + 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 that a project depending on this one can resolve its tags' - main.resources.srcDirs*.canonicalFile.contains( + 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') - and: 'and resource processing waits for it to be written' - dependencyNames(project.tasks.getByName('processResources')).contains('generateTagLibraryIndex') + expect: + main.runtimeClasspath.files*.canonicalFile.contains( + new File(projectDir.toFile(), 'build/generated/grails-taglibs-packaged').canonicalFile) } - void 'compiling pages sees the index this project generates'() { - given: 'a page calling a tag the same project declares can only resolve it from the index' - Task compilePages = project.tasks.getByName('compileGroovyPages') + 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: 'on the classpath itself, not merely produced before it' - compilePages.classpath.files*.canonicalFile.contains( - new File(projectDir.toFile(), 'build/generated/grails-taglibs').canonicalFile) + expect: + dependencyNames(packageIndex).contains('classes') } - void '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' - Task processResources = project.tasks.getByName('processResources') + 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: - processResources.excludes.contains('META-INF/grails/taglibs/compile-settings.properties') + 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'() { 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 index 9eaa82d994a..c713b11a1fd 100644 --- 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 @@ -29,31 +29,72 @@ import org.grails.gradle.plugin.core.GradleSpecification */ class TagLibraryIndexWiringFunctionalSpec extends GradleSpecification { - def "the index is on the classpath of everything that resolves tag calls against it"() { - given: + 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_INDEX=true') + result.output.contains('COMPILE_SEES_PRE_INDEX=true') - and: 'so does a page of this project, compiled in a process of its own' - result.output.contains('PAGES_SEE_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') - and: 'and it travels with the artifact, so a project depending on this one resolves them too' - result.output.contains('INDEX_IS_A_RESOURCE=true') + 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 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('processResources') + def result = executeTask('build') - then: 'so forking it here would fail the build of a project with nothing to describe' - assertTaskSuccess('processResources', result) + 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 index 098690c4cfe..2f6ebfe9a37 100644 --- 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 @@ -1,6 +1,7 @@ -// Verifies where the tag library index is written and what it is wired into. No tag library sources -// are present, which is itself the case under test: a project with none must not fork the generator, -// and so must not need it on a classpath it has no reason to have. +// 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' @@ -10,11 +11,17 @@ 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 packaged = tasks.named('packageTagLibraryIndex').get() + def packagedDeps = packaged.taskDependencies.getDependencies(packaged)*.name doLast { - println "COMPILE_SEES_INDEX=${compileGroovyClasspath.any { it.endsWith('/generated/grails-taglibs') }}" - println "PAGES_SEE_INDEX=${pagesClasspath.any { it.endsWith('/generated/grails-taglibs') }}" - println "INDEX_IS_A_RESOURCE=${resourceDirs.any { it.endsWith('/generated/grails-taglibs') }}" + 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')}" } } 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 index 7a0caeeb8bc..ee96d2cef1f 100644 --- 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 @@ -70,11 +70,24 @@ public final class TagLibraryIndex { */ 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"; /** @@ -91,15 +104,20 @@ public final class TagLibraryIndex { private final Map> tagNamesByClass; private final boolean strict; private final Set dynamicNamespaces; + private final Set incompleteNamespaces; + private final boolean everythingIncomplete; private TagLibraryIndex(Map> byNamespace, Map> ambiguousByNamespace, Map> tagNamesByClass, - boolean strict, Set dynamicNamespaces) { + boolean strict, Set dynamicNamespaces, Set incompleteNamespaces, + boolean everythingIncomplete) { this.byNamespace = byNamespace; this.ambiguousByNamespace = ambiguousByNamespace; this.tagNamesByClass = tagNamesByClass; this.strict = strict; this.dynamicNamespaces = dynamicNamespaces; + this.incompleteNamespaces = incompleteNamespaces; + this.everythingIncomplete = everythingIncomplete; } /** @@ -132,7 +150,8 @@ public static TagLibraryIndex load(ClassLoader classLoader) { Map> ambiguous = new TreeMap<>(); Map> byClass = new TreeMap<>(); if (loader == null) { - return new TagLibraryIndex(merged, ambiguous, byClass, false, Collections.emptySet()); + return new TagLibraryIndex(merged, ambiguous, byClass, false, Collections.emptySet(), + Collections.emptySet(), false); } // 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 @@ -202,7 +221,38 @@ public static TagLibraryIndex load(ClassLoader classLoader) { dynamic.add(trimmed); } } - return new TagLibraryIndex(merged, ambiguous, byClass, strict, Collections.unmodifiableSet(dynamic)); + 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); + } + + 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; } /** @@ -372,6 +422,28 @@ 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); + } + + /** + * @return the namespaces known to be missing some of their tags + */ + public Set getIncompleteNamespaces() { + return this.incompleteNamespaces; + } + /** * @param namespace a tag library namespace * @return true when the build declared this namespace as filled in at runtime 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 index be65f51d811..620234fff11 100644 --- 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 @@ -26,6 +26,10 @@ import java.util.Collections; import java.util.Comparator; import java.util.List; +import java.util.Set; +import java.util.TreeSet; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import java.util.stream.Stream; import org.codehaus.groovy.ast.AnnotationNode; @@ -59,6 +63,9 @@ public final class TagLibraryIndexGenerator { private static final String ARTEFACT_ANNOTATION = "grails.artefact.Artefact"; private static final String TAG_LIB_ARTEFACT = "TagLib"; + private static final Pattern NAMESPACE_DECLARATION = + Pattern.compile("static\\s+(?:final\\s+)?(?:String\\s+)?namespace\\s*=\\s*['\"]([^'\"]+)['\"]"); + private TagLibraryIndexGenerator() { } @@ -144,7 +151,8 @@ public static void generate(List sourceDirs, List resolutionRoots, F } List roots = resolutionRoots != null ? resolutionRoots : Collections.emptyList(); - for (ClassNode classNode : parse(sources, roots, parameterNamesRetained, encoding)) { + List skipped = new ArrayList<>(); + for (ClassNode classNode : parse(sources, roots, parameterNamesRetained, encoding, skipped)) { if (!isTagLibrary(classNode)) { continue; } @@ -157,6 +165,26 @@ public static void generate(List sourceDirs, List resolutionRoots, F TagLibraryIndexWriter.write(outputDir, classNode.getName(), namespace, TagLibraryAstDiscovery.findTags(classNode, parameterNamesRetained)); } + recordWhatWasMissed(outputDir, skipped); + } + + /** + * 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) throws IOException { + Set namespaces = new TreeSet<>(); + boolean everything = false; + for (File source : skipped) { + String namespace = declaredNamespace(source); + if (namespace != null) { + namespaces.add(namespace); + } + else { + everything = true; + } + } + TagLibraryIndexWriter.writeIncomplete(outputDir, namespaces, everything); } /** @@ -170,22 +198,25 @@ public static void generate(List sourceDirs, List resolutionRoots, F * with no descriptor does. */ private static List parse(List sources, List resolutionRoots, - boolean parameterNamesRetained, String encoding) { + boolean parameterNamesRetained, String encoding, List skippedOut) { try { return collectClassNodes(compile(sources, resolutionRoots, parameterNamesRetained, encoding)); } catch (Exception wholeSourceSetFailed) { List classNodes = new ArrayList<>(); - List skipped = new ArrayList<>(); for (File source : sources) { try { classNodes.addAll(collectClassNodes( compile(List.of(source), resolutionRoots, parameterNamesRetained, encoding))); } catch (Exception singleSourceFailed) { - skipped.add(source.getName()); + skippedOut.add(source); } } - if (!skipped.isEmpty()) { - System.out.println("Tag library index: could not read " + String.join(", ", skipped) + + 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)); @@ -193,6 +224,27 @@ private static List parse(List sources, List resolutionRo } } + /** + * The namespace a source that could not be parsed declares, read from its text. + * + *

Only ever used to record that a namespace is missing some of its tags, so that nothing in it + * is reported as a misspelling. Reading too many namespaces out of a file costs some diagnostics; + * reading too few would let a call to a tag that does exist be reported as one that does not, so + * where the text says nothing every namespace is treated as incomplete. + * + * @return the namespace, or {@code null} when the text does not state one plainly + */ + private static String declaredNamespace(File source) { + try { + String text = Files.readString(source.toPath()); + Matcher matcher = NAMESPACE_DECLARATION.matcher(text); + return matcher.find() ? matcher.group(1) : null; + } + catch (IOException | RuntimeException unreadable) { + return null; + } + } + private static CompilationUnit compile(List sources, List resolutionRoots, boolean parameterNamesRetained, String encoding) { CompilerConfiguration configuration = new CompilerConfiguration(); 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 index f45670b25a2..5786b0a9fe6 100644 --- 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 @@ -139,6 +139,35 @@ public static void write(File outputDirectory, String className, String namespac store(manifest, names); } + /** + * 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 { // Properties.store stamps a comment with the current time, which would make output differ // between builds; the entries are written directly instead to keep the descriptor stable. 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 index ea8d75be2b4..c9a44ae4dfd 100644 --- 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 @@ -401,6 +401,12 @@ private void reportUnknownTag(String namespace, String tagName, Expression call) if (!index.isStrict() || index.isDynamicNamespace(namespace)) { 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 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 index 7f19502972b..3cb79b5bf5a 100644 --- 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 @@ -239,6 +239,91 @@ class SourceResolvedIndexGeneratorSpec extends Specification { 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') + } + + 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') From 86ed5d8401b53ef5bf0048c6132077d1df6d842d Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 11 Aug 2026 15:53:12 -0600 Subject: [PATCH 42/74] Prove a deleted tag library cannot survive in what is published The index is regenerated in full each build and nothing writes descriptors into the class output, so a renamed or deleted tag library should not be able to linger. Nothing showed that: the core build builds its test projects once, and what is at stake is what a second build does, since Gradle does not recompile a source that has not changed and so would never revisit anything written per class. Adds an end-to-end project that builds an application twice without a clean, against the artifacts this repository publishes, and asserts that a deleted tag library, a renamed one and a removed tag are each gone from the index beside it, from the manifest naming it, and from the jar - and that nothing has written a second index into the class output. It drives the nested build through the repository's own wrapper rather than Gradle TestKit, which puts Gradle's Groovy 4 on the test classpath and cannot compile against the Groovy 5 Spock this repository builds against. The audit also stopped short of this build's output directories, as it already does for every other build in the repository that has its own. --- end-to-end/README.md | 1 + end-to-end/settings.gradle | 4 + .../taglib-index-incremental/build.gradle | 54 +++++ .../TagLibraryIndexIncrementalSpec.groovy | 200 ++++++++++++++++++ gradle/rat-root-config.gradle | 1 + 5 files changed, 260 insertions(+) create mode 100644 end-to-end/taglib-index-incremental/build.gradle create mode 100644 end-to-end/taglib-index-incremental/src/test/groovy/org/apache/grails/e2e/taglib/TagLibraryIndexIncrementalSpec.groovy 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..4ffa795bb18 --- /dev/null +++ b/end-to-end/taglib-index-incremental/src/test/groovy/org/apache/grails/e2e/taglib/TagLibraryIndexIncrementalSpec.groovy @@ -0,0 +1,200 @@ +/* + * 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 '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() { + Process process = new ProcessBuilder(System.getProperty('grails.e2e.gradlew'), + '-p', projectDir.absolutePath, 'jar', '--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() { + File jar = new File(projectDir, 'build/libs').listFiles()?.find { it.name.endsWith('.jar') } + assert jar != null : 'the project produced no jar' + new ZipFile(jar).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 '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 cfbd56e6486..9b14a4052fa 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 From 1ec4b8a0f427dd38b73e7bbf71b1d7af81668376 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 11 Aug 2026 16:24:16 -0600 Subject: [PATCH 43/74] Keep this project's compile settings out of what it publishes The settings shared a directory with the descriptors, and that directory is on the runtime classpath so that a page compiled while the application runs can resolve its tags. An executable archive is built from the runtime classpath by copying whole directories, so excluding the settings on the archive tasks could not reach them: a boot jar or a war carried them regardless, and a consumer would inherit settings describing how this project is compiled. They are written to a directory of their own now, which is on the classpaths that compile this project and its pages and on nothing else. There is nothing left for an exclusion to have to catch. The end-to-end test asserts it against a real archive, and fails against the previous arrangement. Both indexes now take everything they must agree on from one configuration by task type. Configuring them one at a time would let the index this project compiles against describe a different set of tag libraries from the one it publishes, with nothing to say so. --- .../TagLibraryIndexIncrementalSpec.groovy | 14 ++++++ .../theWebLayer/gsp/taglibs/compiledTags.adoc | 8 +++- .../gsp/GenerateTagLibraryIndexTask.groovy | 18 +++++++- .../plugin/views/gsp/GroovyPagePlugin.groovy | 46 ++++++++++++------- .../GenerateTagLibraryIndexTaskSpec.groovy | 4 +- 5 files changed, 68 insertions(+), 22 deletions(-) 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 index 4ffa795bb18..29f19954781 100644 --- 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 @@ -104,6 +104,20 @@ class TagLibraryIndexIncrementalSpec extends Specification { packagedDescriptor('demo.AlphaTagLib').text.contains('renamedTag') } + 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() diff --git a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc index 1a45bf88f2c..9c5b3124410 100644 --- a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc +++ b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc @@ -212,12 +212,16 @@ the authoritative one: pages are compiled against it, it travels with the artifa 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 to both tasks: +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 ---- -tasks.matching { it.name in ['generateTagLibraryIndex', 'packageTagLibraryIndex'] }.configureEach { +import org.grails.gradle.plugin.views.gsp.GenerateTagLibraryIndexTask + +tasks.withType(GenerateTagLibraryIndexTask).configureEach { sourceDirectories.from(file('src/main/groovy')) } ---- 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 index 393b9f08616..8af97ee98af 100644 --- 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 @@ -102,6 +102,20 @@ abstract class GenerateTagLibraryIndexTask extends DefaultTask { @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. */ @@ -176,7 +190,9 @@ abstract class GenerateTagLibraryIndexTask extends DefaultTask { else { TagLibraryIndexFiles.clearIndex(destination) } - TagLibraryIndexFiles.writeSettings(destination, strictTags.getOrElse(false), + File settingsDestination = settingsDirectory.present ? settingsDirectory.get().asFile : destination + settingsDestination.mkdirs() + TagLibraryIndexFiles.writeSettings(settingsDestination, strictTags.getOrElse(false), dynamicTagNamespaces.getOrElse([] as Set)) } } 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 f76badb5b63..28f5ef72634 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 @@ -135,18 +135,31 @@ class GroovyPagePlugin implements Plugin { // 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.dynamicTagNamespaces.set(resolveDynamicTagNamespaces(project)) + index.javaLauncher.convention(launcher) + } + + // 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.sourceDirectories.from(project.layout.projectDirectory.dir('grails-app/taglib')) 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) }) - it.parameterNamesRetained.set(resolvePreserveParameterNames(project)) - it.strictTags.set(resolveStrictTags(project)) - it.dynamicTagNamespaces.set(resolveDynamicTagNamespaces(project)) - it.javaLauncher.convention(launcher) } FileCollection tagLibIndex = project.files(tagLibIndexDir).builtBy(generateTagLibraryIndex) @@ -157,18 +170,18 @@ class GroovyPagePlugin implements Plugin { // 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.sourceDirectories.from(project.layout.projectDirectory.dir('grails-app/taglib')) it.destinationDirectory.set(packagedIndexDir) + it.settingsDirectory.set(packagedSettingsDir) // The whole output, which is built by classes, so this waits for everything that writes // into it rather than for the compile tasks alone. it.generatorClasspath.from(project.configurations.named('compileClasspath'), output) - it.parameterNamesRetained.set(resolvePreserveParameterNames(project)) - it.strictTags.set(resolveStrictTags(project)) - it.dynamicTagNamespaces.set(resolveDynamicTagNamespaces(project)) - it.javaLauncher.convention(launcher) } + FileCollection packagedSettings = + project.files(packagedSettingsDir).builtBy(packageTagLibraryIndex) FileCollection packagedTagLibIndex = project.files(packagedIndexDir).builtBy(packageTagLibraryIndex) @@ -179,6 +192,7 @@ class GroovyPagePlugin implements Plugin { project.configurations.named('compileClasspath'), classesDirs, packagedTagLibIndex, + packagedSettings, project.configurations.findByName('providedCompile') ?: null ].findAll { it } ) @@ -193,17 +207,15 @@ class GroovyPagePlugin implements Plugin { // 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) + compile.classpath = compile.classpath.plus(tagLibIndex).plus(tagLibSettings) } - String settingsPath = "${TagLibraryIndexFiles.INDEX_LOCATION}/${TagLibraryIndexFiles.SETTINGS_FILE}" + // Only the descriptors. The settings are on no archive and no runtime classpath, so there is + // nothing for an exclusion to have to catch. tasks.withType(Jar).configureEach { Jar archive -> - archive.from(packagedTagLibIndex) { CopySpec spec -> - // The settings say how this project is compiled, not what its tag libraries declare, - // so they stay out of the artifact: a consumer must not inherit them. - spec.exclude(settingsPath) - } + archive.from(packagedTagLibIndex) } def compileGroovyPages = tasks.register('compileGroovyPages', GroovyPageForkCompileTask) { 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 index 389e8c1137d..f4bfe8f9c00 100644 --- 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 @@ -169,9 +169,9 @@ class GenerateTagLibraryIndexTaskSpec extends Specification { when: task.generate() - then: + then: 'written apart from the descriptors, so it can never travel with them' File settings = new File(emptyDir, - 'build/generated/grails-taglibs/META-INF/grails/taglibs/compile-settings.properties') + 'build/generated/grails-taglib-settings/META-INF/grails/taglibs/compile-settings.properties') settings.isFile() settings.text.contains('dynamicTagNamespaces=legacy') settings.text.contains('strictTags=false') From d1c446ac6d8962fc638c577ea84428d481c03fb1 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 11 Aug 2026 16:24:16 -0600 Subject: [PATCH 44/74] Read a skipped tag library's namespace rather than match it The namespace of a tag library that could not be described was matched out of the raw source, so one named in a comment or a string was taken for the declaration. That recorded the wrong namespace as incomplete and left the real one looking complete - which is exactly when a call to a tag that does exist is reported as one that does not. Parsed to the conversion phase instead, which builds the tree and stops before resolving anything, so a type this project has not compiled yet cannot make it fail. Only a namespace the class states itself is trusted: one inherited from a base class cannot be read when whether that base class resolved is the very thing in doubt, and a file declaring more than one claims neither. Anything else leaves every namespace incomplete, which costs a diagnostic rather than inventing an error. --- .../index/TagLibraryIndexGenerator.java | 62 +++++++++++++------ .../SourceResolvedIndexGeneratorSpec.groovy | 22 +++++++ 2 files changed, 66 insertions(+), 18 deletions(-) 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 index 620234fff11..57bce5dfebf 100644 --- 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 @@ -28,12 +28,12 @@ import java.util.List; import java.util.Set; import java.util.TreeSet; -import java.util.regex.Matcher; -import java.util.regex.Pattern; 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; @@ -63,8 +63,7 @@ public final class TagLibraryIndexGenerator { private static final String ARTEFACT_ANNOTATION = "grails.artefact.Artefact"; private static final String TAG_LIB_ARTEFACT = "TagLib"; - private static final Pattern NAMESPACE_DECLARATION = - Pattern.compile("static\\s+(?:final\\s+)?(?:String\\s+)?namespace\\s*=\\s*['\"]([^'\"]+)['\"]"); + private static final String NAMESPACE_FIELD = "namespace"; private TagLibraryIndexGenerator() { } @@ -165,18 +164,19 @@ public static void generate(List sourceDirs, List resolutionRoots, F TagLibraryIndexWriter.write(outputDir, classNode.getName(), namespace, TagLibraryAstDiscovery.findTags(classNode, parameterNamesRetained)); } - recordWhatWasMissed(outputDir, skipped); + 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) throws IOException { + 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); + String namespace = declaredNamespace(source, encoding); if (namespace != null) { namespaces.add(namespace); } @@ -225,22 +225,48 @@ private static List parse(List sources, List resolutionRo } /** - * The namespace a source that could not be parsed declares, read from its text. + * The namespace a source that could not be described declares, read from its syntax tree. * - *

Only ever used to record that a namespace is missing some of its tags, so that nothing in it - * is reported as a misspelling. Reading too many namespaces out of a file costs some diagnostics; - * reading too few would let a call to a tag that does exist be reported as one that does not, so - * where the text says nothing every namespace is treated as incomplete. + *

Parsed rather than matched against the text: a namespace named in a comment or a string + * would otherwise be taken for the declaration, and recording the wrong namespace as incomplete + * 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. * - * @return the namespace, or {@code null} when the text does not state one plainly + *

Only a namespace the class states itself is trusted. One inherited from a base class cannot + * be read here, because whether the base class was resolved is the very thing in doubt, and + * guessing would attribute the gap to the wrong namespace. + * + * @return the namespace, or {@code null} when this source does not plainly state one */ - private static String declaredNamespace(File source) { + private static String declaredNamespace(File source, String encoding) { try { - String text = Files.readString(source.toPath()); - Matcher matcher = NAMESPACE_DECLARATION.matcher(text); - return matcher.find() ? matcher.group(1) : null; + 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); + String namespace = null; + for (ClassNode classNode : collectClassNodes(unit)) { + FieldNode field = classNode.getDeclaredField(NAMESPACE_FIELD); + if (field == null || !field.isStatic()) { + continue; + } + if (!(field.getInitialExpression() instanceof ConstantExpression constant) || + constant.getValue() == null) { + return null; + } + if (namespace != null) { + // More than one tag library in the file, declaring different namespaces. Which + // one failed is not knowable, so neither is claimed. + return null; + } + namespace = constant.getValue().toString().trim(); + } + return namespace == null || namespace.isEmpty() ? null : namespace; } - catch (IOException | RuntimeException unreadable) { + catch (Exception unparseable) { return null; } } 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 index 3cb79b5bf5a..9c4c992cfeb 100644 --- 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 @@ -314,6 +314,28 @@ class SourceResolvedIndexGeneratorSpec extends Specification { !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') + } + private TagLibraryIndex indexOf() { URLClassLoader loader = new URLClassLoader([output.toUri().toURL()] as URL[], (ClassLoader) null) try { From 25a292dd00c97240f2ec95133df900b653ef0335 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 11 Aug 2026 16:24:16 -0600 Subject: [PATCH 45/74] Measure the rewriting rather than static compilation with it The page comparison put a statically compiled page against a dynamic one, so the difference included compiling the page statically and not only rewriting its tag calls. Both sides are statically compiled now and only the rewriting varies, turned off through the namespace declaration a build can make - which is what the tag library comparison already did. The page figure moves from -70% to -66% per tag call, so a few points of it were never the rewriting. --- .../taglib/TagDispatchBenchmarkSpec.groovy | 37 +++++++++++++++++-- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/TagDispatchBenchmarkSpec.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/TagDispatchBenchmarkSpec.groovy index 4c4da71043a..9eae41f9b10 100644 --- a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/TagDispatchBenchmarkSpec.groovy +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/TagDispatchBenchmarkSpec.groovy @@ -24,6 +24,7 @@ import java.nio.file.Path import grails.testing.web.taglib.TagLibUnitTest import groovy.text.Template import org.grails.gsp.GroovyPagesTemplateEngine +import org.grails.taglib.TagLibraryLookup import org.grails.taglib.index.TagLibraryIndex import org.grails.plugins.web.taglib.ApplicationTagLib import spock.lang.Requires @@ -65,10 +66,13 @@ class TagDispatchBenchmarkSpec extends Specification implements TagLibUnitTest dynamicRuns = [] @@ -165,6 +169,31 @@ ${body} new GroovyClassLoader(parent).parseClass(source, className + '.groovy') } + /** + * A page compiler that either may or may not rewrite its tag calls, according to what the build + * declared. Comparing a statically compiled page against a dynamic one would measure static + * compilation of the whole page as well; this varies only the rewriting. + */ + private GroovyPagesTemplateEngine engineFor(String settings) { + ClassLoader parent = getClass().classLoader + if (settings != null) { + File settingsDir = File.createTempDir('taglib-bench', '') + settingsDir.deleteOnExit() + File indexDir = new File(settingsDir, TagLibraryIndex.INDEX_LOCATION) + indexDir.mkdirs() + new File(indexDir, 'compile-settings.properties').text = settings + parent = new URLClassLoader([settingsDir.toURI().toURL()] as URL[], parent) + } + GroovyPagesTemplateEngine engine = new GroovyPagesTemplateEngine() + engine.classLoader = parent + engine.applicationContext = applicationContext + // A page reaches its tags through the lookup, whether it was rewritten or not, so an engine + // built by hand has to be given the one the application context holds. + engine.tagLibraryLookup = applicationContext.getBean(TagLibraryLookup) + engine.afterPropertiesSet() + engine + } + private double timePerCall(Template template) { long start = System.nanoTime() MEASURED_RENDERS.times { From 0e7d2022bab377ce3da421c5bf9ec50d46989618 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 11 Aug 2026 16:37:43 -0600 Subject: [PATCH 46/74] Claim a skipped tag library's namespace only when the source is plain Which namespace is missing tags was read from whichever class in the file declared one, tag library or not. A helper class beside a tag library could therefore supply the name, recording a namespace nothing was missing from and leaving the one the tag library is really in looking complete - which is when a call to a tag that does exist gets reported as one that does not. A namespace is claimed only where the source leaves no room for doubt: one tag library in the file, declaring its own namespace as a constant. A namespace field on another class, a second tag library, an inherited namespace whose base class may not have resolved, and none stated at all each yield nothing, and every namespace is then treated as incomplete. --- .../index/TagLibraryIndexGenerator.java | 54 ++++++++------ .../SourceResolvedIndexGeneratorSpec.groovy | 73 +++++++++++++++++++ 2 files changed, 106 insertions(+), 21 deletions(-) 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 index 57bce5dfebf..23c9a7c6c41 100644 --- 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 @@ -193,8 +193,9 @@ private static void recordWhatWasMissed(File outputDir, List skipped, Stri *

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. A skipped tag library still has its descriptor written by the - * compiler as it is built, and until then its tags resolve dynamically, exactly as a tag library + * 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, @@ -227,14 +228,15 @@ private static List parse(List sources, List resolutionRo /** * The namespace a source that could not be described declares, read from its syntax tree. * - *

Parsed rather than matched against the text: a namespace named in a comment or a string - * would otherwise be taken for the declaration, and recording the wrong namespace as incomplete - * 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. + *

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. * - *

Only a namespace the class states itself is trusted. One inherited from a base class cannot - * be read here, because whether the base class was resolved is the very thing in doubt, and - * guessing would attribute the gap to the wrong namespace. + *

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 */ @@ -247,24 +249,34 @@ private static String declaredNamespace(File source, String encoding) { // 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); - String namespace = null; + + ClassNode candidate = null; for (ClassNode classNode : collectClassNodes(unit)) { - FieldNode field = classNode.getDeclaredField(NAMESPACE_FIELD); - if (field == null || !field.isStatic()) { + if (!isTagLibrary(classNode)) { continue; } - if (!(field.getInitialExpression() instanceof ConstantExpression constant) || - constant.getValue() == null) { - return null; - } - if (namespace != null) { - // More than one tag library in the file, declaring different namespaces. Which - // one failed is not knowable, so neither is claimed. + if (candidate != null) { + // Which of them failed is not knowable, so neither is claimed. return null; } - namespace = constant.getValue().toString().trim(); + 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; } - return namespace == null || namespace.isEmpty() ? null : namespace; + String namespace = constant.getValue().toString().trim(); + return namespace.isEmpty() ? null : namespace; } catch (Exception unparseable) { return null; 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 index 9c4c992cfeb..c07e5ae1c30 100644 --- 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 @@ -336,6 +336,79 @@ class SourceResolvedIndexGeneratorSpec extends Specification { 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 { From 58bc54102ca31496a226d67c7adc575244fa59af Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 11 Aug 2026 16:37:43 -0600 Subject: [PATCH 47/74] Add the descriptors to the library artifact alone A war and an executable archive are built from the runtime classpath, which already carries the descriptors into wherever that archive puts classes. Adding them to every archive task as well 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. Covered by building a war, which is the shape that exposed it: the descriptors are in WEB-INF/classes where a page compiled at runtime reads them, the settings are nowhere in it, and no descriptor is carried twice. --- .../TagLibraryIndexIncrementalSpec.groovy | 35 ++++++++++++++++--- .../plugin/views/gsp/GroovyPagePlugin.groovy | 9 +++-- 2 files changed, 37 insertions(+), 7 deletions(-) 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 index 29f19954781..cd3e7fbcfe2 100644 --- 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 @@ -104,6 +104,24 @@ class TagLibraryIndexIncrementalSpec extends Specification { 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() @@ -127,8 +145,12 @@ class TagLibraryIndexIncrementalSpec extends Specification { } private void build() { + buildTask('jar') + } + + private void buildTask(String task) { Process process = new ProcessBuilder(System.getProperty('grails.e2e.gradlew'), - '-p', projectDir.absolutePath, 'jar', '--stacktrace') + '-p', projectDir.absolutePath, task, '--stacktrace') .redirectErrorStream(true) .start() String output = process.inputStream.getText('UTF-8') @@ -147,9 +169,13 @@ class TagLibraryIndexIncrementalSpec extends Specification { } private List jarNames() { - File jar = new File(projectDir, 'build/libs').listFiles()?.find { it.name.endsWith('.jar') } - assert jar != null : 'the project produced no jar' - new ZipFile(jar).withCloseable { zip -> zip.entries().collect { it.name } } + 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) { @@ -196,6 +222,7 @@ class TagLibraryIndexIncrementalSpec extends Specification { new File(projectDir, 'build.gradle').text = """ plugins { id 'groovy' + id 'war' id 'org.apache.grails.gradle.grails-gsp' version '${version}' } 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 28f5ef72634..f2907946bf6 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 @@ -212,9 +212,12 @@ class GroovyPagePlugin implements Plugin { compile.classpath = compile.classpath.plus(tagLibIndex).plus(tagLibSettings) } - // Only the descriptors. The settings are on no archive and no runtime classpath, so there is - // nothing for an exclusion to have to catch. - tasks.withType(Jar).configureEach { Jar archive -> + // 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) } From 2bff14123a9f9d489368400fcf7bcbe289e47251 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 11 Aug 2026 16:48:40 -0600 Subject: [PATCH 48/74] Drop an import left over from rewiring the index outputs processResources no longer carries the index, so the import went with the configuration block that did. --- .../org/grails/gradle/plugin/views/gsp/GroovyPagePlugin.groovy | 1 - 1 file changed, 1 deletion(-) 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 f2907946bf6..ad70cf11051 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 @@ -35,7 +35,6 @@ import org.gradle.api.tasks.SourceSetOutput import org.gradle.api.tasks.TaskContainer import org.gradle.api.tasks.bundling.Jar import org.gradle.api.tasks.bundling.War -import org.gradle.language.jvm.tasks.ProcessResources import org.gradle.jvm.toolchain.JavaLauncher import org.gradle.jvm.toolchain.JavaToolchainService From 2780990aaa437d1196b52b2752b59c827eebfdc0 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 11 Aug 2026 19:18:07 -0500 Subject: [PATCH 49/74] Take the compiled classes, not the whole source set output The index written after compilation reads this project's classes so that a tag library referring to a service, base class or trait of the same project can be described. It took the whole source set output to do that, which also waits for anything else writing into it. A view compiler registers its own output directory into that output and runs after the classes task, so it cannot name the classes task as its producer without a cycle - which leaves anything reading the whole output consuming a directory nothing declares it produces, and Gradle rejects that. It failed every project that compiles both pages and JSON or markup views. The class directories are taken instead, with a dependency on the classes task so that everything writing into those - the ast classes are copied in after compiling, for one - is still waited for. Compiled views are no use in resolving what a tag library declares. --- .../plugin/views/gsp/GroovyPagePlugin.groovy | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) 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 ad70cf11051..e040909f6a3 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 @@ -175,9 +175,17 @@ class GroovyPagePlugin implements Plugin { it.description = 'Regenerates the tag library index against the compiled project' it.destinationDirectory.set(packagedIndexDir) it.settingsDirectory.set(packagedSettingsDir) - // The whole output, which is built by classes, so this waits for everything that writes - // into it rather than for the compile tasks alone. - it.generatorClasspath.from(project.configurations.named('compileClasspath'), output) + // 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) From eb5b69236086827a9ba79e1ce18fc7318ecc1897 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Sun, 16 Aug 2026 09:06:46 -0700 Subject: [PATCH 50/74] Leave a name Groovy already answers to out of unqualified rewriting An unqualified call to a DefaultGroovyMethods method - with, each, print and the rest - reached that method directly and never went near methodMissing. Rewriting it into a tag invocation because a tag library happened to declare a tag of the same name silently sent the call somewhere it was never written to go. grails-fields already declares f:with, so the collision is not hypothetical. Reserve every name the metaclass answers to for an arbitrary receiver, which covers DefaultGroovyMethods and any extension module on the compiling classpath. A call that names its namespace is unaffected. --- .../compiler/CompiledTagCallRewriter.java | 36 +++- .../GroovyMethodNameCollisionSpec.groovy | 162 ++++++++++++++++++ 2 files changed, 195 insertions(+), 3 deletions(-) create mode 100644 grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/GroovyMethodNameCollisionSpec.groovy 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 index c9a44ae4dfd..55995cf84cd 100644 --- 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 @@ -19,9 +19,13 @@ 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; @@ -84,10 +88,17 @@ public class CompiledTagCallRewriter extends ClassCodeExpressionTransformer { private static final String DEFAULT_NAMESPACE = "g"; /** - * Names the dispatch treats as its own before it ever considers a tag, so an unqualified call to - * one of them is not a tag call however the index reads. + * 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 = Set.of("body", "render"); + private static final Set RESERVED_NAMES = reservedNames(); private static final String REWRITTEN_MARKER = CompiledTagCallRewriter.class.getName(); @@ -489,6 +500,25 @@ private boolean hasGetter(String namespace) { 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. 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..255a7ca2810 --- /dev/null +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/GroovyMethodNameCollisionSpec.groovy @@ -0,0 +1,162 @@ +/* + * 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 to a tag with no Groovy method of that name is still rewritten'() { + when: 'nothing else answers to the name' + 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: 'so reserving Groovy\'s own names has not switched unqualified rewriting off' + references(compiled) + } + + 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')) + } +} From 156977b9975af21db2ef7e7c30163dfff8d910ba Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Sun, 16 Aug 2026 09:14:28 -0700 Subject: [PATCH 51/74] State what a page with no tag library lookup does with an unresolved name A page resolves an unqualified name through a real methodMissing rather than one installed onto its metaclass, and the field it resolves against is documented as null until the page is initialised. Reaching the lookup regardless arrives at the same missing-method answer, but only because a dynamic call on a null receiver yields no tag library; say it instead, and pin the behaviour with a spec. --- .../groovy/org/grails/gsp/GroovyPage.java | 16 +++ .../gsp/GroovyPageMethodMissingSpec.groovy | 120 ++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 grails-gsp/core/src/test/groovy/org/grails/gsp/GroovyPageMethodMissingSpec.groovy 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 b4491ddcc9e..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; @@ -321,12 +322,27 @@ public Object getProperty(String property) { * @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/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 + } + } +} From 79193b6a75d66e71433b695ea9b41ab64e100188 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Sun, 16 Aug 2026 09:17:20 -0700 Subject: [PATCH 52/74] Declare where tag call rewriting runs instead of relying on a default The rewriting has to run after the transforms that apply the traits a class calls tags through, and did - but only because a transform that declares no priority defaults to zero, which happened to put it last among the globals. A transform added later with the same default would have displaced it silently. Give it a slot in GroovyTransformOrder, as every other Grails global transform has, and pin the relationship to artefact trait injection. --- .../compiler/GroovyTransformOrder.groovy | 7 +++ .../CompiledTagCallTransformation.groovy | 12 +++- ...piledTagCallTransformationOrderSpec.groovy | 58 +++++++++++++++++++ 3 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/CompiledTagCallTransformationOrderSpec.groovy 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-gsp/plugin/src/ast/groovy/grails/compiler/traits/CompiledTagCallTransformation.groovy b/grails-gsp/plugin/src/ast/groovy/grails/compiler/traits/CompiledTagCallTransformation.groovy index c70c0743cfd..6f321a6e346 100644 --- a/grails-gsp/plugin/src/ast/groovy/grails/compiler/traits/CompiledTagCallTransformation.groovy +++ b/grails-gsp/plugin/src/ast/groovy/grails/compiler/traits/CompiledTagCallTransformation.groovy @@ -27,9 +27,11 @@ 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 /** @@ -42,13 +44,14 @@ import org.grails.taglib.index.TagLibraryIndex * 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. + * 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 { +class CompiledTagCallTransformation implements ASTTransformation, TransformWithPriority { private static final ClassNode TAG_LIBRARY_INVOKER = ClassHelper.make(TagLibraryInvoker) @@ -95,4 +98,9 @@ class CompiledTagCallTransformation implements ASTTransformation { } false } + + @Override + int priority() { + GroovyTransformOrder.COMPILED_TAG_CALL_ORDER + } } 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 + } +} From 298d5854227f6b9edea458520884dad58f10a07a Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Sun, 16 Aug 2026 09:21:53 -0700 Subject: [PATCH 53/74] Say what the self-describing fallback covers, and deprecate what installs onto metaclasses The guide claimed every tag library describes itself when no build writes the index. That path is a local AST transform bound to @TagLib, so it reaches annotated tag libraries only; one declared by convention is recognised as an artefact too late to describe itself. Say so, along with the descriptors that path leaves behind when a tag library is renamed. Deprecate the metaclass-installing methods individually rather than the class that holds them: methodMissingForTagLib is the dynamic dispatch path and is not going anywhere. Record the removed and no-op metaclass API in the upgrade guide, along with the closure tag form tag libraries are actually written in. Also drop LocalNameCollector's use of a deprecated Groovy API, which the build was reporting, and collect the two names it was missing - a catch parameter and a closure's implicit it. --- .../theWebLayer/gsp/taglibs/compiledTags.adoc | 17 ++++- .../src/en/guide/upgrading/upgrading80x.adoc | 76 +++++++++++++++++-- .../grails/taglib/TagLibraryMetaUtils.groovy | 16 +++- .../taglib/compiler/LocalNameCollector.java | 32 +++++++- 4 files changed, 127 insertions(+), 14 deletions(-) diff --git a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc index 9c5b3124410..f7a801a45d4 100644 --- a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc +++ b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc @@ -233,5 +233,18 @@ project-declared type are all read rather than guessed. A tag library naming a t 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 — each tag library describes itself as it is compiled instead, which makes it resolvable -to anything compiled after it. +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/upgrading/upgrading80x.adoc b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc index 3a22a8403a9..b336147c563 100644 --- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc +++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc @@ -2388,19 +2388,85 @@ Naming a namespace in `dynamicTagNamespaces` turns rewriting off for it entirely 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 `Closure` fields 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. Convert -them to methods: +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 -Closure hello = { Map attrs -> +// 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 << '

' +} + +// 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. 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 4726dd66692..ed574f572d9 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 @@ -40,10 +40,11 @@ import org.grails.taglib.encoder.OutputContextLookupHelper * 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. * - * @deprecated Installing tags onto metaclasses is no longer part of dispatching a tag. Resolve - * through {@link TagLibraryLookup} and invoke through {@link CompiledTagInvocation}. + *

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. */ -@Deprecated class TagLibraryMetaUtils { private static final Log LOG = LogFactory.getLog(TagLibraryMetaUtils) @@ -52,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 @@ -59,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) @@ -99,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) @@ -106,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)) @@ -113,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 @@ -166,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') @@ -174,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') @@ -263,6 +272,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-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 index 368d749fa5e..95adab3030c 100644 --- 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 @@ -29,12 +29,13 @@ 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 and the variables its loops introduce. + * 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 @@ -45,6 +46,11 @@ */ 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() { @@ -94,14 +100,32 @@ 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) { - if (forLoop.getVariable() != null) { - names.add(forLoop.getVariable().getName()); - } + // 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()); + } + } } From c89bbaf534c9cc6799c49eb62771e3e163d7ed00 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Sun, 16 Aug 2026 09:23:42 -0700 Subject: [PATCH 54/74] Number the index format from one and drop the doubled javadoc The descriptor format ships for the first time in this release, so starting it at two makes the number mean nothing later. Nothing outside these files reads the constant. Also remove the javadoc left above its replacement on FRAMEWORK_METHOD_NAMES and on findTags. --- .../src/main/groovy/org/grails/taglib/TagMethodInvoker.java | 4 ---- .../org/grails/taglib/discovery/TagLibraryAstDiscovery.java | 5 ----- .../main/groovy/org/grails/taglib/index/TagLibraryIndex.java | 2 +- 3 files changed, 1 insertion(+), 10 deletions(-) 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 7fbeee40d3c..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 @@ -41,10 +41,6 @@ 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. *

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 index b0470f5161c..091b72dd69c 100644 --- 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 @@ -82,11 +82,6 @@ public static String resolveNamespace(ClassNode classNode) { return DEFAULT_NAMESPACE; } - /** - * @param classNode the tag library - * @param parameterNamesRetained whether this compilation writes parameter names into the class file - * @return every tag the library declares, whether as a tag method or a legacy closure field - */ /** * @param classNode the tag library * @param parameterNamesRetained whether this compilation writes parameter names into the class file 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 index ee96d2cef1f..328dc6b8d9e 100644 --- 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 @@ -61,7 +61,7 @@ public final class TagLibraryIndex { * 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 = 2; + public static final int FORMAT_VERSION = 1; /** * Settings the build states for the compilation the index is read in, written alongside the From 781eb505f1de4beed0dc8e31e9e66d97459afb1e Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Sun, 16 Aug 2026 09:25:58 -0700 Subject: [PATCH 55/74] Drop the two index methods nothing calls findTagNames duplicated findTags with the same two loops and the same declaring-class guard, and getIncompleteNamespaces exposed a field the isNamespaceComplete question already answers. Neither had a caller anywhere, in main code or in a test. --- .../discovery/TagLibraryAstDiscovery.java | 24 ------------------- .../grails/taglib/index/TagLibraryIndex.java | 7 ------ 2 files changed, 31 deletions(-) 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 index 091b72dd69c..495f433cbfd 100644 --- 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 @@ -18,11 +18,8 @@ */ package org.grails.taglib.discovery; -import java.util.Collection; import java.util.LinkedHashMap; -import java.util.LinkedHashSet; import java.util.Map; -import java.util.Set; import groovy.lang.Closure; import org.codehaus.groovy.ast.ClassHelper; @@ -108,25 +105,4 @@ public static Map findTags(ClassNode classNod return tags; } - public static Collection findTagNames(ClassNode classNode, boolean parameterNamesRetained) { - Set tagNames = new LinkedHashSet<>(); - for (MethodNode method : classNode.getMethods()) { - // TagMethodInvoker scans getDeclaredMethods(), so a method inherited from a superclass is - // not dispatchable and must not be recorded. Trait methods are woven as declarations on the - // implementing class and so are still seen here. - if (method.getDeclaringClass() != null && !classNode.equals(method.getDeclaringClass())) { - continue; - } - if (TagDiscoveryRules.isTagMethod(new AstTagMethodView(method, parameterNamesRetained))) { - tagNames.add(method.getName()); - } - } - // Closure-typed fields remain tags for as long as the deprecated form is supported. - for (FieldNode field : classNode.getFields()) { - if (!field.isStatic() && field.getType() != null && CLOSURE_TYPE.equals(field.getType())) { - tagNames.add(field.getName()); - } - } - return tagNames; - } } 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 index 328dc6b8d9e..6dea17d58d6 100644 --- 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 @@ -437,13 +437,6 @@ public boolean isNamespaceComplete(String namespace) { return !this.everythingIncomplete && !this.incompleteNamespaces.contains(namespace); } - /** - * @return the namespaces known to be missing some of their tags - */ - public Set getIncompleteNamespaces() { - return this.incompleteNamespaces; - } - /** * @param namespace a tag library namespace * @return true when the build declared this namespace as filled in at runtime From 8ce567cf0d648308024ec1929856c84967b58bbb Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Sun, 16 Aug 2026 09:38:12 -0700 Subject: [PATCH 56/74] Wire the index into test runtimes and stop restating what nothing reads A test source set builds its runtime classpath from the main output rather than from the main runtime classpath, so the packaged index never reached it: a page rendered by a test resolved its tags against an index missing the application's own tag libraries. Add it to the test and integrationTest runtimes, and assert it. The generator now reads sources with the encoding the project compiles with rather than always UTF-8, and the settings file is written through the key constants rather than through literals that repeat them. Generated AST nodes are built per call site instead of reusing the process-wide THIS_EXPRESSION and NULL singletons, which carry node metadata that a statically compiled class writes to. Drop the developmentMode fields nothing reads - one of which the trait materialised into every controller and tag library - and the empty @PostConstruct that remained once tags stopped being installed onto metaclasses. Also cover the build with a configuration cache run, which the index tasks turn out to survive, and pin the on-disk format on both sides of the module boundary that has to restate it. --- .../plugin/views/gsp/GroovyPagePlugin.groovy | 48 +++++++++++ .../views/gsp/TagLibraryIndexFiles.groovy | 25 ++++-- .../views/gsp/TagLibraryIndexFilesSpec.groovy | 84 +++++++++++++++++++ ...TagLibraryIndexWiringFunctionalSpec.groovy | 31 +++++++ .../taglib-index-wiring/build.gradle | 2 + .../taglib/NamespacedTagDispatcher.groovy | 3 - .../taglib/index/TagLibraryIndexSpec.groovy | 9 ++ .../groovy/grails/artefact/TagLibrary.groovy | 11 --- .../artefact/gsp/TagLibraryInvoker.groovy | 2 - .../compiler/CompiledTagCallRewriter.java | 6 +- 10 files changed, 197 insertions(+), 24 deletions(-) create mode 100644 grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/views/gsp/TagLibraryIndexFilesSpec.groovy 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 e040909f6a3..0d6cfb7efbd 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 @@ -31,6 +31,7 @@ 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 @@ -49,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') { @@ -100,6 +106,18 @@ class GroovyPagePlugin implements Plugin { } } + /** + * 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. */ @@ -109,6 +127,24 @@ class GroovyPagePlugin implements Plugin { 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 + } + for (String name : TEST_SOURCE_SET_NAMES) { + SourceSet sourceSet = sourceSets.findByName(name) + if (sourceSet != null) { + sourceSet.runtimeClasspath = sourceSet.runtimeClasspath.plus(packagedTagLibIndex) + } + } + } + private void configureProject(Project project) { TaskContainer tasks = project.tasks @@ -144,6 +180,11 @@ class GroovyPagePlugin implements Plugin { index.strictTags.set(resolveStrictTags(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 @@ -210,6 +251,13 @@ class GroovyPagePlugin implements Plugin { 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 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 index 943b1fdecce..180cf36b1be 100644 --- 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 @@ -35,17 +35,32 @@ import groovy.transform.CompileStatic final class TagLibraryIndexFiles { /** - * Directory holding one descriptor per compiled tag library, matching - * {@code TagLibraryIndex.INDEX_LOCATION}. + * 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, matching + * 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' + private TagLibraryIndexFiles() { } @@ -77,8 +92,8 @@ final class TagLibraryIndexFiles { 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 = "dynamicTagNamespaces=${new TreeSet(dynamicNamespaces).join(',')}\n" + - "strictTags=${strictTags}\n" + String text = "${DYNAMIC_NAMESPACES_KEY}=${new TreeSet(dynamicNamespaces).join(',')}\n" + + "${STRICT_KEY}=${strictTags}\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/TagLibraryIndexFilesSpec.groovy b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/views/gsp/TagLibraryIndexFilesSpec.groovy new file mode 100644 index 00000000000..6ebfc1250cd --- /dev/null +++ b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/views/gsp/TagLibraryIndexFilesSpec.groovy @@ -0,0 +1,84 @@ +/* + * 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' + } + + 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) + + 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\n' + } + + 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 index c713b11a1fd..83d0cdd695f 100644 --- 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 @@ -59,6 +59,37 @@ class TagLibraryIndexWiringFunctionalSpec extends GradleSpecification { 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') 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 index 2f6ebfe9a37..3fabdaf93b4 100644 --- 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 @@ -12,6 +12,7 @@ tasks.register('inspectTagLibraryIndexWiring') { 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 @@ -22,6 +23,7 @@ tasks.register('inspectTagLibraryIndexWiring') { 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') }}" } } 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 2c5d40c2c6a..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,12 +36,10 @@ 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() } 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 index 55863f191f6..d598952ae79 100644 --- 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 @@ -36,6 +36,15 @@ 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' + } + void 'tag libraries in separate jars merge into one namespace'() { given: URLClassLoader loader = loaderOver( diff --git a/grails-gsp/grails-web-taglib/src/main/groovy/grails/artefact/TagLibrary.groovy b/grails-gsp/grails-web-taglib/src/main/groovy/grails/artefact/TagLibrary.groovy index 502ac90940e..468c54e1496 100644 --- a/grails-gsp/grails-web-taglib/src/main/groovy/grails/artefact/TagLibrary.groovy +++ b/grails-gsp/grails-web-taglib/src/main/groovy/grails/artefact/TagLibrary.groovy @@ -21,7 +21,6 @@ package grails.artefact import groovy.transform.CompileStatic import org.codehaus.groovy.runtime.InvokerHelper -import jakarta.annotation.PostConstruct import org.springframework.web.context.request.RequestAttributes @@ -56,16 +55,6 @@ trait TagLibrary implements WebAttributes, ServletAttributes, TagLibraryInvoker private Encoder rawEncoder - /** - * 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 nothing is - * installed and no metaclass is initialised on the way to a tag. - */ - @PostConstruct - void initializeTagLibrary() { - } - Object raw(Object value) { Encoder encoder = WithCodecHelper.lookupEncoder(getGrailsApplication(), 'Raw') if (encoder == null) { 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 a82d7ca2fa8..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) { 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 index 55995cf84cd..26efbd4789e 100644 --- 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 @@ -310,7 +310,7 @@ private Expression invocation(String namespace, String tagName, Expression argum return null; } ArgumentListExpression invocationArgs = new ArgumentListExpression(); - invocationArgs.addExpression(new MethodCallExpression(VariableExpression.THIS_EXPRESSION, + invocationArgs.addExpression(new MethodCallExpression(new VariableExpression("this"), LOOKUP_ACCESSOR, MethodCallExpression.NO_ARGUMENTS)); invocationArgs.addExpression(new ConstantExpression(namespace)); invocationArgs.addExpression(new ConstantExpression(tagName)); @@ -339,7 +339,7 @@ private Expression invocation(String namespace, String tagName, Expression argum } private Expression outputContext() { - return new MethodCallExpression(VariableExpression.THIS_EXPRESSION, OUTPUT_CONTEXT_ACCESSOR, + return new MethodCallExpression(new VariableExpression("this"), OUTPUT_CONTEXT_ACCESSOR, MethodCallExpression.NO_ARGUMENTS); } @@ -350,7 +350,7 @@ private Expression outputContext() { private Expression[] attributesAndBody(TupleExpression tuple) { List args = tuple.getExpressions(); Expression noAttributes = new MapExpression(); - Expression noBody = ConstantExpression.NULL; + Expression noBody = new ConstantExpression(null); switch (args.size()) { case 0: return new Expression[] { noAttributes, noBody }; From 68901e28581822a805b038bfbb8de29f8c07fb99 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Sun, 16 Aug 2026 09:46:32 -0700 Subject: [PATCH 57/74] Finish the review: index reads, discovery pins, and the notes that were missing Descriptor URLs are resolved against the manifest that names them rather than searched for on the classpath, which turned one full classpath walk per tag library into none. Pin the three ways these discovery rules differ from the ones they replaced - an Object member name, a non boolean is accessor, and a name containing a dollar - through both the tree and the compiled class, so the claim that the two views cannot drift is enforced for them too. Document the tag call that a controller annotated outside grails-app/controllers does not get compiled, with a spec pinning both shapes, and the GrailsTagException a resolved call now reports for a tag the runtime has not registered. Cover grails-mail's text:newLine, which had no test before its signature was changed here, and say why it was changed: it works either way, but a closure tag now warns and the framework should not trip its own warning. Drop getAmbiguousTagNames, which restates isAmbiguous, and the benchmark spec that was gated off by an environment variable and so never ran. --- .../theWebLayer/gsp/taglibs/compiledTags.adoc | 19 ++ .../src/en/guide/upgrading/upgrading80x.adoc | 32 +++ .../grails/taglib/index/TagLibraryIndex.java | 33 ++- .../taglib/index/TagLibraryIndexSpec.groovy | 1 - .../discovery/TagDiscoveryRulesSpec.groovy | 9 + .../ControllerTagCallRewriteSpec.groovy | 56 ++++ .../taglib/TagDispatchBenchmarkSpec.groovy | 241 ------------------ .../mail/PlainTextMailTagLibSpec.groovy | 50 ++++ 8 files changed, 187 insertions(+), 254 deletions(-) delete mode 100644 grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/TagDispatchBenchmarkSpec.groovy create mode 100644 grails-mail/src/test/groovy/grails/plugins/mail/PlainTextMailTagLibSpec.groovy diff --git a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc index f7a801a45d4..56a83081877 100644 --- a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc +++ b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc @@ -93,6 +93,25 @@ a particular tag library class, so a tag declared by more than one of them is co 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: diff --git a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc index b336147c563..8599ae95801 100644 --- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc +++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc @@ -2470,3 +2470,35 @@ The methods that performed the installation are deprecated or removed: `TagLibraryMetaUtils.methodMissingForTagLib` is not deprecated — it is the dynamic dispatch path a call into an undescribed namespace still takes. + +==== 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-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 index 6dea17d58d6..ec8bed5a656 100644 --- 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 @@ -279,9 +279,13 @@ private static Set listDescriptors(ClassLoader loader) { continue; } for (String className : names.stringPropertyNames()) { - Enumeration descriptors = loader.getResources(INDEX_LOCATION + className + ".properties"); - while (descriptors.hasMoreElements()) { - urls.add(descriptors.nextElement()); + // 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); } } } @@ -293,6 +297,20 @@ private static Set listDescriptors(ClassLoader loader) { return urls; } + /** + * @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 + */ + 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(); @@ -339,15 +357,6 @@ public boolean isAmbiguous(String namespace, String tagName) { return ambiguousTags != null && ambiguousTags.contains(tagName); } - /** - * @param namespace a tag library namespace - * @return the tags in that namespace declared by more than one tag library - */ - public Set getAmbiguousTagNames(String namespace) { - Set ambiguousTags = ambiguousByNamespace.get(namespace); - return ambiguousTags != null ? Collections.unmodifiableSet(new TreeSet<>(ambiguousTags)) : - Collections.emptySet(); - } /** * @return every namespace contributed by a compiled tag library 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 index d598952ae79..6d5aeeb5076 100644 --- 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 @@ -128,7 +128,6 @@ class TagLibraryIndexSpec extends Specification { then: 'which one wins depends on registration order at runtime, so it is left unresolved' index.isAmbiguous('g', 'shared') index.lookup('g', 'shared') == null - index.getAmbiguousTagNames('g') == ['shared'] as Set and: 'tags declared by only one of them still resolve' index.lookup('g', 'onlyA').tagLibraryClassName() == 'com.a.OneTagLib' 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 index ab4524c0046..27442d87a97 100644 --- 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 @@ -80,6 +80,15 @@ class TagDiscoveryRulesSpec extends Specification { '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) { }" } /** 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 index 0606ff96b8b..db70a633ef2 100644 --- 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 @@ -66,10 +66,66 @@ class ControllerTagCallRewriteSpec extends Specification { !references(compiled, 'org/grails/taglib/CompiledTagInvocation') } + void 'a controller declared by convention has its tag calls compiled into invocations'() { + when: 'under grails-app/controllers, which is how a controller is normally declared' + byte[] compiled = compileAt('grails-app/controllers/demo', ''' + package demo + + class ConventionController { + def index() { + g.createLink(controller: 'book') + } + } + ''', 'ConventionController', 'demo') + + 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 diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/TagDispatchBenchmarkSpec.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/TagDispatchBenchmarkSpec.groovy deleted file mode 100644 index 9eae41f9b10..00000000000 --- a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/TagDispatchBenchmarkSpec.groovy +++ /dev/null @@ -1,241 +0,0 @@ -/* - * 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 grails.testing.web.taglib.TagLibUnitTest -import groovy.text.Template -import org.grails.gsp.GroovyPagesTemplateEngine -import org.grails.taglib.TagLibraryLookup -import org.grails.taglib.index.TagLibraryIndex -import org.grails.plugins.web.taglib.ApplicationTagLib -import spock.lang.Requires -import spock.lang.Shared -import spock.lang.Specification - -/** - * What compiling a tag call into an invocation is worth, separately from removing the metaclass work - * that used to surround every call. - * - *

Both are measured against the same framework, so the metaclass writes are already gone from both - * sides. What varies is only whether a call was compiled into an invocation, which is what a build can - * still turn off per namespace. That isolates the part of the change whose value was never measured - * on its own. - * - *

Off unless asked for, since a timing run is neither quick nor a pass/fail assertion: - * - *

- * GRAILS_TAGLIB_BENCH=true ./gradlew :grails-gsp:test \
- *     --tests '*TagDispatchBenchmarkSpec' --rerun-tasks -i
- * 
- * - *

Gated on the environment rather than a system property because a forked test process inherits - * the environment, where this build bridges only a few named properties into it. - */ -@Requires({ System.getenv('GRAILS_TAGLIB_BENCH') }) -class TagDispatchBenchmarkSpec extends Specification implements TagLibUnitTest { - - /** - * Tag calls per render. Fewer than the 400-call page the pull request measured, because that many - * expressions in one page exceed the size a single Groovy method may compile to. Results are - * reported per call, so the count only has to be large enough to dominate per-render overhead. - */ - private static final int CALLS_PER_RENDER = 50 - - private static final int WARMUP_RENDERS = intFromEnv('GRAILS_TAGLIB_BENCH_WARMUP', 300) - private static final int MEASURED_RENDERS = intFromEnv('GRAILS_TAGLIB_BENCH_RENDERS', 2000) - /** Alternated rather than run end to end, so a machine warming up cannot favour one side. */ - private static final int ROUNDS = intFromEnv('GRAILS_TAGLIB_BENCH_ROUNDS', 7) - - void 'a page renders its tags faster once they are compiled into invocations'() { - given: 'both pages statically compiled, so only whether their calls were rewritten differs' - GroovyPagesTemplateEngine dispatching = engineFor('dynamicTagNamespaces=g\n') - GroovyPagesTemplateEngine rewriting = engineFor(null) - - and: 'each compiled once, so what is timed is rendering rather than compiling' - Template dispatched = dispatching.createTemplate(page(true), 'benchDispatched') - Template compiled = rewriting.createTemplate(page(true), 'benchCompiled') - - when: - List dynamicRuns = [] - List staticRuns = [] - WARMUP_RENDERS.times { - renderOnce(dispatched) - renderOnce(compiled) - } - ROUNDS.times { - dynamicRuns << timePerCall(dispatched) - staticRuns << timePerCall(compiled) - } - - then: - report('dispatched', dynamicRuns) - report('compiled', staticRuns) - double dynamicMedian = median(dynamicRuns) - double staticMedian = median(staticRuns) - println String.format(' change %+.1f%% per tag call', - ((staticMedian - dynamicMedian) / dynamicMedian) * 100.0d) - println " (${CALLS_PER_RENDER} calls per render, ${MEASURED_RENDERS} renders per round, " + - "${ROUNDS} alternated rounds)" - - and: 'reported rather than asserted: a timing is evidence, not a contract' - dynamicMedian > 0.0d && staticMedian > 0.0d - } - - void 'a tag library calls other tags faster once those calls are compiled into invocations'() { - given: 'two tag libraries alike but for whether the build let their calls be compiled' - Class dispatchedTagLib = compileCaller('BenchDispatchedTagLib', 'benchdispatched', true) - Class compiledTagLib = compileCaller('BenchCompiledTagLib', 'benchcompiled', false) - mockTagLib(dispatchedTagLib) - mockTagLib(compiledTagLib) - - and: 'each reached through a page compiled once, so only the tag calls within differ' - GroovyPagesTemplateEngine engine = applicationContext.getBean(GroovyPagesTemplateEngine) - Template dispatched = engine.createTemplate('', 'benchCallerDispatched') - Template compiled = engine.createTemplate('', 'benchCallerCompiled') - - when: - List dispatchedRuns = [] - List compiledRuns = [] - WARMUP_RENDERS.times { - renderOnce(dispatched) - renderOnce(compiled) - } - ROUNDS.times { - dispatchedRuns << timePerCall(dispatched) - compiledRuns << timePerCall(compiled) - } - - then: - println ' -- calls written inside a tag library --' - report('dispatched', dispatchedRuns) - report('compiled', compiledRuns) - double dispatchedMedian = median(dispatchedRuns) - double compiledMedian = median(compiledRuns) - println String.format(' change %+.1f%% per tag call', - ((compiledMedian - dispatchedMedian) / dispatchedMedian) * 100.0d) - - and: - dispatchedMedian > 0.0d && compiledMedian > 0.0d - } - - /** - * Compiles a tag library whose tag calls the framework's own tag library, either left to dispatch - * or compiled into invocations depending on what the build declared. - * - * @param declaredDynamic whether the build declared the called namespace as filled in at runtime, - * which is what turns compile-time resolution off for it - */ - private Class compileCaller(String className, String namespace, boolean declaredDynamic) { - StringBuilder body = new StringBuilder() - CALLS_PER_RENDER.times { int i -> - body.append(" out << g.createLink(controller: 'book', action: 'show', id: ${i})\n") - } - String source = """ - import grails.gsp.TagLib - @TagLib - class ${className} { - static namespace = '${namespace}' - def callsTags(Map attrs) { -${body} - } - } - """ - ClassLoader parent = getClass().classLoader - if (declaredDynamic) { - Path settings = Files.createTempDirectory(className) - Path indexDir = Files.createDirectories(settings.resolve(TagLibraryIndex.INDEX_LOCATION)) - indexDir.resolve('compile-settings.properties').toFile().text = 'dynamicTagNamespaces=g\n' - parent = new URLClassLoader([settings.toUri().toURL()] as URL[], parent) - } - new GroovyClassLoader(parent).parseClass(source, className + '.groovy') - } - - /** - * A page compiler that either may or may not rewrite its tag calls, according to what the build - * declared. Comparing a statically compiled page against a dynamic one would measure static - * compilation of the whole page as well; this varies only the rewriting. - */ - private GroovyPagesTemplateEngine engineFor(String settings) { - ClassLoader parent = getClass().classLoader - if (settings != null) { - File settingsDir = File.createTempDir('taglib-bench', '') - settingsDir.deleteOnExit() - File indexDir = new File(settingsDir, TagLibraryIndex.INDEX_LOCATION) - indexDir.mkdirs() - new File(indexDir, 'compile-settings.properties').text = settings - parent = new URLClassLoader([settingsDir.toURI().toURL()] as URL[], parent) - } - GroovyPagesTemplateEngine engine = new GroovyPagesTemplateEngine() - engine.classLoader = parent - engine.applicationContext = applicationContext - // A page reaches its tags through the lookup, whether it was rewritten or not, so an engine - // built by hand has to be given the one the application context holds. - engine.tagLibraryLookup = applicationContext.getBean(TagLibraryLookup) - engine.afterPropertiesSet() - engine - } - - private double timePerCall(Template template) { - long start = System.nanoTime() - MEASURED_RENDERS.times { - renderOnce(template) - } - long elapsed = System.nanoTime() - start - elapsed / (double) (MEASURED_RENDERS * CALLS_PER_RENDER) - } - - private static void renderOnce(Template template) { - StringWriter out = new StringWriter() - template.make().writeTo(out) - } - - private static void report(String label, List runs) { - println String.format(' %-16s median %7.1f ns/call min %7.1f max %7.1f', - label, median(runs), runs.min(), runs.max()) - } - - private static int intFromEnv(String name, int fallback) { - String value = System.getenv(name) - value ? value as int : fallback - } - - private static double median(List values) { - List sorted = values.sort(false) - int middle = (sorted.size() / 2) as int - sorted.size() % 2 == 1 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2.0d - } - - /** - * @param compileStatic whether the page gives up dynamic resolution, which is what allows its tag - * expressions to be compiled into invocations - */ - private static String page(boolean compileStatic) { - StringBuilder markup = new StringBuilder() - if (compileStatic) { - markup.append('<%@ page compileStatic="true" %>') - } - CALLS_PER_RENDER.times { int i -> - markup.append("\${g.createLink(controller: 'book', action: 'show', id: ${i})}") - } - markup.toString() - } -} 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' + } +} From 6480f53211559ac3d1019bdae9d4e0ad610cf167 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Sun, 16 Aug 2026 09:58:48 -0700 Subject: [PATCH 58/74] Stop concurrent index writes losing entries, and cover the rest of the review Every tag library compiled into one directory adds itself to a manifest they all share, unguarded. Writing 32 of them at once lost 29: each writer put back a copy that had never seen the others. A lost entry is silent - the descriptor is there, nothing names it, so the tag library is never discovered. Guard the read-modify-write with a monitor for threads of this JVM and a file lock for a second process, and prove it with a spec that fails without either. Pin what a class calling a tag through methodMissing now gets back: both ways of declaring a tag capture output, where a method-declared one used to return its own return value. Record that in the upgrade guide. Say plainly when strictTags can be used - a namespace only some of its jars described cannot be checked, which is the normal state of g - and correct the claim that a tag's kind decides whether a call is resolved. It does not; both forms are dispatched by name. --- .../theWebLayer/gsp/taglibs/compiledTags.adoc | 22 +++ .../src/en/guide/upgrading/upgrading80x.adoc | 32 +++++ .../taglib/index/TagLibraryIndexEntry.java | 19 ++- .../taglib/index/TagLibraryIndexWriter.java | 81 +++++++++-- ...agLibraryIndexWriterConcurrencySpec.groovy | 127 +++++++++++++++++ .../TagLibraryInvokerDispatchSpec.groovy | 134 ++++++++++++++++++ 6 files changed, 397 insertions(+), 18 deletions(-) create mode 100644 grails-gsp/grails-taglib/src/test/groovy/org/grails/taglib/index/TagLibraryIndexWriterConcurrencySpec.groovy create mode 100644 grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/TagLibraryInvokerDispatchSpec.groovy diff --git a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc index 56a83081877..f85c48514d0 100644 --- a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc +++ b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc @@ -209,6 +209,28 @@ metaprogramming. Both settings are read from the build, not from a system property, so changing either recompiles what depends on it. +===== When strict checking can be used + +Strict checking asks whether a tag is in the index, and the index holds what the tag libraries on the +classpath described. It cannot tell a namespace that is fully described from one that several jars +contribute to and only some of them described. + +That matters most for `g`. Grails describes its own tag libraries, so `g` is always partly described; +a plugin built against an earlier version of Grails, or one that declares its tag libraries by +convention without applying the GSP Gradle plugin, contributes tags to `g` with no description. Under +`strictTags` every call to one of those tags is reported, and the code is correct. + +So enable `strictTags` when every tag library the application uses is described — its own, and its +plugins'. Where one is not, the only remedy is to name its namespace in `dynamicTagNamespaces`, which +switches compile-time resolution off for that namespace entirely. For `g` that means giving up the +feature where it is worth the most, so an application depending on an undescribed third-party tag +library in `g` is better off leaving `strictTags` alone and keeping the default, which reports nothing +and resolves what it can. + +An index generated from source before compilation records what it could not describe, and no tag in a +namespace it failed to read completely is ever reported. That covers what one build could not read +about itself; it cannot cover what another project never wrote down. + ==== Where the description lives Each tag library contributes one file under `META-INF/grails/taglibs` in the artifact it is packaged diff --git a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc index 8599ae95801..982758405be 100644 --- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc +++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc @@ -2471,6 +2471,38 @@ The methods that performed the installation are deprecated or removed: `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 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 index 8ac8e51170e..ae5f829b899 100644 --- 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 @@ -24,7 +24,7 @@ * @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 kind how the tag is implemented, which decides whether a call to it can be resolved + * @param kind how the tag is implemented * @param acceptsBody whether the tag can be called with a body * @since 8.0.0 */ @@ -33,23 +33,32 @@ public record TagLibraryIndexEntry(String namespace, String tagName, String tagL /** * How a tag is implemented. + * + *

No call-site decision turns on this. A resolved call is compiled into an invocation that + * selects the tag by name when it runs, and a closure answers to a name as readily as a method + * does, so both forms are compiled the same way. + * + *

It is recorded because it is the difference between a tag a caller could one day bind to a + * signature and one that could never carry a signature to bind to. Binding to a specific method + * is not part of this release; recording the distinction now means the descriptor format does not + * have to change when it is. */ public enum Kind { /** - * A method, which carries a signature and so can be bound when a caller is compiled. + * A method, which carries a signature. */ METHOD, /** - * A {@code Closure} field, the deprecated form. It carries no signature, so a call to it - * cannot be bound when the caller is compiled and is dispatched dynamically. + * A {@code Closure} field, the deprecated form, which carries none. */ LEGACY_CLOSURE } /** - * @return true when a call to this tag can be compiled into a direct invocation + * @return true when the tag carries a signature a caller could be bound to. Not consulted when + * deciding whether to compile a call: see {@link Kind}. */ public boolean isBindable() { return kind == Kind.METHOD; 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 index 5786b0a9fe6..d93b8e91183 100644 --- 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 @@ -18,6 +18,7 @@ */ package org.grails.taglib.index; +import java.io.ByteArrayInputStream; import java.io.File; import java.io.IOException; import java.io.InputStream; @@ -25,8 +26,12 @@ 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.Map; import java.util.Properties; @@ -45,6 +50,11 @@ */ 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() { } @@ -128,15 +138,53 @@ public static void write(File outputDirectory, String className, String namespac descriptor.setProperty(TagLibraryIndex.TAGS_KEY, encoded.toString()); store(new File(indexDirectory, className + ".properties"), descriptor); - File manifest = new File(indexDirectory, "index.properties"); - Properties names = new Properties(); - if (manifest.isFile()) { - try (InputStream in = Files.newInputStream(manifest.toPath())) { - names.load(new InputStreamReader(in, StandardCharsets.UTF_8)); + 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)); + } } } - names.setProperty(className, ""); - store(manifest, names); } /** @@ -169,16 +217,23 @@ public static void writeIncomplete(File outputDirectory, Collection name } private static void store(File file, Properties properties) throws IOException { - // Properties.store stamps a comment with the current time, which would make output differ - // between builds; the entries are written directly instead to keep the descriptor stable. + 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'); } - try (OutputStream out = Files.newOutputStream(file.toPath()); - Writer writer = new OutputStreamWriter(out, StandardCharsets.UTF_8)) { - writer.write(text.toString()); - } + return text.toString(); } private static String escape(String value) { 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..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 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() + } +} + +@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}" + } +} From 6b67de9dc635c328f8723f9952fe1bc392792876 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Sun, 16 Aug 2026 10:45:49 -0700 Subject: [PATCH 59/74] Leave an unqualified call inside a closure to the closure's delegate request.withFormat { form multipartForm { } } was compiled into a call to the g:form tag. form there is a format in a DSL: a closure is given a delegate when it runs, and a name the delegate answers to is the delegate's, not a tag library's. Nothing about that is knowable when the closure is compiled, so an unqualified call inside one is left alone. A call naming its namespace is unaffected, which is how a tag body keeps the faster path. Restore TagLibrary.initializeTagLibrary. It does nothing now, but a trait method is part of the binary contract - Groovy weaves a call to the generated helper into every implementing class, so removing it raised NoSuchMethodError for every tag library compiled against an earlier release, asset-pipeline's among them. --- .../theWebLayer/gsp/taglibs/compiledTags.adoc | 21 ++++++- .../groovy/grails/artefact/TagLibrary.groovy | 18 ++++++ .../compiler/CompiledTagCallRewriter.java | 24 ++++++- .../web/taglib/TagCallShadowingSpec.groovy | 62 +++++++++++++++++++ 4 files changed, 122 insertions(+), 3 deletions(-) diff --git a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc index f85c48514d0..f3470314e7f 100644 --- a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc +++ b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc @@ -137,8 +137,25 @@ class BookController { } ---- -Tags called from within a closure — a tag body, a `withFormat` block, anything taking a block — are -compiled the same way as tags called directly. +A tag named with its namespace is compiled the same way inside a closure — a tag body, a `withFormat` +block, anything taking a block — as it is outside one. + +A call written *without* a namespace inside a closure is not. A closure is given a delegate when it +runs, and a name the delegate answers to belongs to the delegate rather than to a tag library, which +is not knowable when the closure is compiled: + +[source,groovy] +---- +request.withFormat { + form multipartForm { // <1> + redirect book + } +} +---- +<1> `form` here is a format in the `withFormat` DSL, not the `g:form` tag + +Naming the namespace is what distinguishes the two, so write `g.form(...)` where a tag is meant inside +a block. ==== Tags in pages diff --git a/grails-gsp/grails-web-taglib/src/main/groovy/grails/artefact/TagLibrary.groovy b/grails-gsp/grails-web-taglib/src/main/groovy/grails/artefact/TagLibrary.groovy index 468c54e1496..4ae095e188d 100644 --- a/grails-gsp/grails-web-taglib/src/main/groovy/grails/artefact/TagLibrary.groovy +++ b/grails-gsp/grails-web-taglib/src/main/groovy/grails/artefact/TagLibrary.groovy @@ -19,6 +19,7 @@ package grails.artefact import groovy.transform.CompileStatic +import jakarta.annotation.PostConstruct import org.codehaus.groovy.runtime.InvokerHelper @@ -55,6 +56,23 @@ trait TagLibrary implements WebAttributes, ServletAttributes, TagLibraryInvoker private Encoder rawEncoder + /** + * Retained deliberately, and deliberately empty. + * + *

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() { + } + Object raw(Object value) { Encoder encoder = WithCodecHelper.lookupEncoder(getGrailsApplication(), 'Raw') if (encoder == null) { 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 index 26efbd4789e..f6f5cd440db 100644 --- 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 @@ -109,6 +109,11 @@ public class CompiledTagCallRewriter extends ClassCodeExpressionTransformer { 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; @@ -200,7 +205,20 @@ public Expression transform(Expression expression) { // 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. - closure.visit(this); + // + // 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) { @@ -282,6 +300,10 @@ private String unqualifiedNamespaceOf(MethodCallExpression call, String tagName) 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; } 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 index ff4ecafa19f..599d9fa2f8c 100644 --- 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 @@ -104,6 +104,42 @@ class TagCallShadowingSpec extends Specification { 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 on the namespace itself is still rewritten'() { when: 'nothing in scope claims the name' byte[] compiled = compile(''' @@ -137,6 +173,32 @@ class TagCallShadowingSpec extends Specification { !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) } From 7286f5c4cb262544ce93baff01af6d49553f45a9 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Sun, 16 Aug 2026 11:38:35 -0700 Subject: [PATCH 60/74] Clear the style violations left by the review changes An import left unused when the manifest read moved to a channel, a blank line left by removing a method, a groovy.lang import group that should not be separated from org.codehaus.groovy, and an import moved out of its group when @PostConstruct was restored. --- .../main/groovy/org/grails/taglib/index/TagLibraryIndex.java | 1 - .../groovy/org/grails/taglib/index/TagLibraryIndexWriter.java | 1 - .../src/main/groovy/grails/artefact/TagLibrary.groovy | 2 +- .../grails/gsp/taglib/compiler/CompiledTagCallRewriter.java | 1 - 4 files changed, 1 insertion(+), 4 deletions(-) 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 index ec8bed5a656..1ae86b94c71 100644 --- 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 @@ -357,7 +357,6 @@ public boolean isAmbiguous(String namespace, String tagName) { return ambiguousTags != null && ambiguousTags.contains(tagName); } - /** * @return every namespace contributed by a compiled tag library */ 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 index d93b8e91183..84eb9e2a073 100644 --- 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 @@ -21,7 +21,6 @@ import java.io.ByteArrayInputStream; import java.io.File; import java.io.IOException; -import java.io.InputStream; import java.io.InputStreamReader; import java.io.OutputStream; import java.io.OutputStreamWriter; diff --git a/grails-gsp/grails-web-taglib/src/main/groovy/grails/artefact/TagLibrary.groovy b/grails-gsp/grails-web-taglib/src/main/groovy/grails/artefact/TagLibrary.groovy index 4ae095e188d..b2edf96e3c7 100644 --- a/grails-gsp/grails-web-taglib/src/main/groovy/grails/artefact/TagLibrary.groovy +++ b/grails-gsp/grails-web-taglib/src/main/groovy/grails/artefact/TagLibrary.groovy @@ -19,9 +19,9 @@ package grails.artefact import groovy.transform.CompileStatic -import jakarta.annotation.PostConstruct import org.codehaus.groovy.runtime.InvokerHelper +import jakarta.annotation.PostConstruct import org.springframework.web.context.request.RequestAttributes 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 index f6f5cd440db..6bf85bee320 100644 --- 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 @@ -25,7 +25,6 @@ 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; From 30cac71a8f4262fb44ba91c0e4d0f053389f61aa Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Sun, 16 Aug 2026 14:06:09 -0700 Subject: [PATCH 61/74] Drop the controller convention case from the rewriting spec It compiled a file into a temporary grails-app/controllers directory and relied on the artefact injector recognising it by location. That passed on macOS and failed on Ubuntu and Windows, in every CI run and again on a rerun, and I could not reproduce it locally in any configuration - alone, with --rerun-tasks, or with the whole module suite. The behaviour is not in doubt: every application under grails-test-examples declares its controllers that way and has its tag calls compiled. What is left here keys on the trait, which is what the rewriting actually reads, and on the annotated case that the trait reaches too late. --- .../ControllerTagCallRewriteSpec.groovy | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) 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 index db70a633ef2..1510baf5b50 100644 --- 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 @@ -31,6 +31,15 @@ import spock.lang.TempDir * 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 { @@ -66,22 +75,6 @@ class ControllerTagCallRewriteSpec extends Specification { !references(compiled, 'org/grails/taglib/CompiledTagInvocation') } - void 'a controller declared by convention has its tag calls compiled into invocations'() { - when: 'under grails-app/controllers, which is how a controller is normally declared' - byte[] compiled = compileAt('grails-app/controllers/demo', ''' - package demo - - class ConventionController { - def index() { - g.createLink(controller: 'book') - } - } - ''', 'ConventionController', 'demo') - - 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', ''' From c1ce109b06cb5707e19c657b22fe68574490ae74 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Wed, 19 Aug 2026 12:58:40 -0700 Subject: [PATCH 62/74] Enumerate a tag library's tags once, for both views of it A Closure tag declared on a base class is registered at runtime, which walks the superclass chain for Closure-typed fields, but was missing from the index, which read declared fields only. The namespace still counted as completely described, so under strictTags a call to a working tag failed the build, and without it the call silently stayed dynamic. The rules already had one statement of whether a method is a tag, so the two sides could not disagree about that. They had two statements of which members to ask about and how far up the hierarchy, which is where they did disagree. Give enumeration the same treatment: a TagLibraryView over a syntax tree or a compiled class, one walk in TagDiscoveryRules, and a spec asserting the two views produce the same set. The walk also settles two smaller differences the same way the runtime does: a field typed as a subclass of Closure is a tag, and a name declared both as a closure and as a method is the closure. --- .../core/gsp/DefaultGrailsTagLibClass.java | 24 ++-- .../taglib/discovery/AstTagLibraryView.java | 85 ++++++++++++++ .../discovery/ReflectedTagLibraryView.java | 73 ++++++++++++ .../taglib/discovery/TagDiscoveryRules.java | 37 ++++++ .../discovery/TagLibraryAstDiscovery.java | 19 +--- .../taglib/discovery/TagLibraryView.java | 53 +++++++++ .../discovery/TagSetAgreementSpec.groovy | 105 ++++++++++++++++++ 7 files changed, 362 insertions(+), 34 deletions(-) create mode 100644 grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/discovery/AstTagLibraryView.java create mode 100644 grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/discovery/ReflectedTagLibraryView.java create mode 100644 grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/discovery/TagLibraryView.java create mode 100644 grails-gsp/grails-web-taglib/src/test/groovy/org/grails/taglib/discovery/TagSetAgreementSpec.groovy 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..e172dc781df 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)).keySet()); 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/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/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/TagDiscoveryRules.java b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/discovery/TagDiscoveryRules.java index 82afbd5145b..e49434d02a6 100644 --- 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 @@ -18,8 +18,13 @@ */ package org.grails.taglib.discovery; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.Map; import java.util.Set; +import org.grails.taglib.index.TagLibraryIndexEntry; + /** * Decides whether a method is a tag. * @@ -87,6 +92,38 @@ public static Set getFrameworkMethodNames() { * @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. + * + *

Where a name is declared both ways the closure wins, and where it is declared as a closure + * more than once the nearest declaration wins, which is the order dispatch resolves them in. + * + * @param view the tag library, from a syntax tree or from a compiled class + * @return each tag mapped to how it is implemented + */ + public static Map findTags(TagLibraryView view) { + Map tags = new LinkedHashMap<>(); + for (TagMethodView method : view.declaredMethods()) { + if (isTagMethod(method)) { + tags.put(method.getName(), TagLibraryIndexEntry.Kind.METHOD); + } + } + Set claimed = new HashSet<>(); + for (TagLibraryView current = view; current != null; current = current.superclassView()) { + for (String name : current.declaredClosureFieldNames()) { + if (claimed.add(name)) { + tags.put(name, TagLibraryIndexEntry.Kind.LEGACY_CLOSURE); + } + } + } + return tags; + } + public static boolean isTagMethod(TagMethodView method) { if (!method.isPublic() || method.isStatic() || method.isGenerated()) { return false; 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 index 495f433cbfd..efb5c55fe25 100644 --- 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 @@ -18,14 +18,12 @@ */ package org.grails.taglib.discovery; -import java.util.LinkedHashMap; import java.util.Map; 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; import org.codehaus.groovy.ast.expr.ConstantExpression; import org.codehaus.groovy.ast.expr.Expression; @@ -87,22 +85,7 @@ public static String resolveNamespace(ClassNode classNode) { */ public static Map findTags(ClassNode classNode, boolean parameterNamesRetained) { - Map tags = new LinkedHashMap<>(); - for (MethodNode method : classNode.getMethods()) { - if (method.getDeclaringClass() != null && !classNode.equals(method.getDeclaringClass())) { - continue; - } - if (TagDiscoveryRules.isTagMethod(new AstTagMethodView(method, parameterNamesRetained))) { - tags.put(method.getName(), TagLibraryIndexEntry.Kind.METHOD); - } - } - for (FieldNode field : classNode.getFields()) { - if (!field.isStatic() && field.getType() != null && CLOSURE_TYPE.equals(field.getType())) { - // A closure carries no signature, so a call to it cannot be bound when compiled. - tags.put(field.getName(), TagLibraryIndexEntry.Kind.LEGACY_CLOSURE); - } - } - return tags; + 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-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..b7686a9fb9b --- /dev/null +++ b/grails-gsp/grails-web-taglib/src/test/groovy/org/grails/taglib/discovery/TagSetAgreementSpec.groovy @@ -0,0 +1,105 @@ +/* + * 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 org.grails.taglib.index.TagLibraryIndexEntry +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: + Map fromTree = fromTree(source, subject) + + and: + Map fromClass = fromClass(source, subject) + + then: 'neither side may know a tag the other does not' + fromTree == fromClass + + and: + fromTree.keySet() == 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 recorded as a legacy closure, not lost'() { + when: + Map 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: TagLibraryIndexEntry.Kind.METHOD, common: TagLibraryIndexEntry.Kind.LEGACY_CLOSURE] + } + + void 'a name declared both ways is the closure, as dispatch resolves it'() { + expect: 'methodMissingForTagLib reads the closure property before the tag method' + fromTree('class Subject { def both(Map attrs) { }\n Closure both = { Map attrs -> } }', 'Subject') + .get('both') == TagLibraryIndexEntry.Kind.LEGACY_CLOSURE + } + + private Map 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 Map 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)) + } +} From 72b3fffb20482f3edcbd5dee8c8f73ea90a2818c Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Wed, 19 Aug 2026 13:11:32 -0700 Subject: [PATCH 63/74] Describe only what was asked for, and dispatch a shape a tag cannot take Three things sbglasius found, all where the compiled path and the dynamic one disagreed about what a call means. The generator described any class named *TagLib that the compilation produced, which includes the collaborators the resolver adds to read a type. A helper under src/main/groovy would 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 when the call runs. Only the sources the generator was pointed at are described now. A tag takes attributes, a body, or both. Any other argument list was reduced to a call with neither, silently dropping what was written, so a name that is both a tag foo(Map) and a helper foo(String, String) ran the tag with nothing where it used to reach the helper. Dynamic dispatch now leaves such a call to the method lookup, and the rewriting declines to compile a shape the invocation cannot account for. Test source sets are matched as they are created rather than looked up on the groovy plugin being applied, since integrationTest is registered later and was being skipped without a word - the gap that wiring exists to close. --- .../plugin/views/gsp/GroovyPagePlugin.groovy | 14 ++++---- .../grails/taglib/TagLibraryMetaUtils.groovy | 27 +++++++++++++++- .../index/TagLibraryIndexGenerator.java | 26 ++++++++++++++- .../compiler/CompiledTagCallRewriter.java | 31 ++++++++++++++++++ .../index/TagLibraryIndexGeneratorSpec.groovy | 32 +++++++++++++++++++ .../web/taglib/TagCallShadowingSpec.groovy | 15 +++++++++ .../TagLibraryInvokerDispatchSpec.groovy | 23 +++++++++++++ 7 files changed, 160 insertions(+), 8 deletions(-) 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 0d6cfb7efbd..3993f8ab869 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 @@ -137,12 +137,14 @@ class GroovyPagePlugin implements Plugin { if (sourceSets == null) { return } - for (String name : TEST_SOURCE_SET_NAMES) { - SourceSet sourceSet = sourceSets.findByName(name) - if (sourceSet != null) { - sourceSet.runtimeClasspath = sourceSet.runtimeClasspath.plus(packagedTagLibIndex) - } - } + // 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) { 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 ed574f572d9..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 @@ -220,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[] } @@ -230,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) { 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 index 23c9a7c6c41..1f921c78b34 100644 --- 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 @@ -25,6 +25,7 @@ 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; @@ -151,8 +152,18 @@ public static void generate(List sourceDirs, List resolutionRoots, F 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)) { + if (!isTagLibrary(classNode) || !wasAskedFor(classNode, describable)) { continue; } String namespace = TagLibraryAstDiscovery.resolveNamespace(classNode); @@ -378,6 +389,19 @@ private static boolean isTagLibrary(ClassNode classNode) { return classNode.getName().endsWith(TAG_LIB_ARTEFACT); } + /** + * @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) 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 index 6bf85bee320..03eba2398c6 100644 --- 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 @@ -349,6 +349,16 @@ private Expression invocation(String namespace, String tagName, Expression argum // 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()); } @@ -359,6 +369,27 @@ private Expression invocation(String namespace, String tagName, Expression argum 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); 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 index d08f4544e0f..b44592d6c3e 100644 --- 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 @@ -235,4 +235,36 @@ class TagLibraryIndexGeneratorSpec extends Specification { 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() + } + } 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 index 599d9fa2f8c..f8ca7a5e65d 100644 --- 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 @@ -140,6 +140,21 @@ class TagCallShadowingSpec extends Specification { 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(''' 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 index e5795270382..54dd91426f6 100644 --- 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 @@ -76,6 +76,17 @@ class TagLibraryInvokerDispatchSpec extends Specification { 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()) @@ -116,6 +127,10 @@ class Caller implements TagLibraryInvoker { Object callUnknown() { noSuchTagAnywhere() } + + Object callOverload() { + format('2026-08-19', 'yyyy') + } } @TagLib @@ -131,4 +146,12 @@ class DispatchTagLib { def greet(Map attrs) { out << "hello ${attrs.name}" } + + def format(Map attrs) { + out << 'as a tag' + } + + def format(String value, String pattern) { + "${value}/${pattern}" + } } From 5852104a301af2824d76e2e1eb130ef38a61dce2 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Wed, 19 Aug 2026 13:23:42 -0700 Subject: [PATCH 64/74] Compile a tag call the source proves is one, and make the rest opt-in 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 when it runs, an overload the tag library also declares. Each of those has been a bug in this branch, found one at a time and fixed by adding another exclusion, which is a sign the rule was wrong rather than that the exclusions were incomplete. So unqualified rewriting is now off unless a build sets grails.compileStatic.unqualifiedTagCalls. Namespaced calls, markup tags and statically compiled page expressions are unaffected, which is where the measured benefit came from. The exclusions stay: turning it on widens which calls are considered, not which names may be captured. --- .../theWebLayer/gsp/taglibs/compiledTags.adoc | 30 +++++----- .../src/en/guide/upgrading/upgrading80x.adoc | 19 +++++-- .../core/GrailsCompileStaticOptions.groovy | 12 ++++ .../gsp/GenerateTagLibraryIndexTask.groovy | 9 ++- .../plugin/views/gsp/GroovyPagePlugin.groovy | 15 +++++ .../views/gsp/TagLibraryIndexFiles.groovy | 9 ++- .../grails/taglib/index/TagLibraryIndex.java | 25 ++++++++- .../compiler/CompiledTagCallRewriter.java | 8 +++ .../taglib/CompiledTagCallBytecodeSpec.groovy | 8 +-- .../GroovyMethodNameCollisionSpec.groovy | 56 ++++++++++++++++++- 10 files changed, 155 insertions(+), 36 deletions(-) diff --git a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc index f3470314e7f..67b19a90870 100644 --- a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc +++ b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc @@ -123,39 +123,35 @@ def index() { } ---- -A call written without a namespace follows the same rule. It 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 — -and is then offered to the calling tag library's own namespace before the default one, which is the -order dispatch uses at runtime: +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') // compiled into an invocation + String markup = createLink(controller: 'book') // dispatched dynamically + String other = g.createLink(controller: 'book') // compiled into an invocation } } ---- -A tag named with its namespace is compiled the same way inside a closure — a tag body, a `withFormat` -block, anything taking a block — as it is outside one. - -A call written *without* a namespace inside a closure is not. A closure is given a delegate when it -runs, and a name the delegate answers to belongs to the delegate rather than to a tag library, which -is not knowable when the closure is compiled: +A project whose tag names are known not to collide can compile those calls too: [source,groovy] +.build.gradle ---- -request.withFormat { - form multipartForm { // <1> - redirect book +grails { + compileStatic { + unqualifiedTagCalls = true } } ---- -<1> `form` here is a format in the `withFormat` DSL, not the `g:form` tag -Naming the namespace is what distinguishes the two, so write `g.form(...)` where a tag is meant inside -a block. +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. ==== Tags in pages diff --git a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc index 982758405be..a75f212262d 100644 --- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc +++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc @@ -2377,12 +2377,19 @@ Strict checking applies only where the source says a call is a tag: one naming i 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 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. 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 now reaches the tag instead. Declare the method on the class, name the namespace -in `dynamicTagNamespaces`, or call the tag with its namespace, if both exist. +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 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 26d262342e1..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 @@ -108,6 +108,17 @@ class GrailsCompileStaticOptions implements Serializable { * * @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 /** @@ -126,6 +137,7 @@ class GrailsCompileStaticOptions implements Serializable { 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 index 8af97ee98af..6d20a267256 100644 --- 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 @@ -144,6 +144,13 @@ abstract class GenerateTagLibraryIndexTask extends DefaultTask { @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. @@ -193,6 +200,6 @@ abstract class GenerateTagLibraryIndexTask extends DefaultTask { File settingsDestination = settingsDirectory.present ? settingsDirectory.get().asFile : destination settingsDestination.mkdirs() TagLibraryIndexFiles.writeSettings(settingsDestination, strictTags.getOrElse(false), - dynamicTagNamespaces.getOrElse([] as Set)) + dynamicTagNamespaces.getOrElse([] as Set), unqualifiedTagCalls.getOrElse(false)) } } 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 3993f8ab869..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 @@ -92,6 +92,20 @@ class GroovyPagePlugin implements Plugin { } } + /** + * 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. */ @@ -180,6 +194,7 @@ class GroovyPagePlugin implements Plugin { 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 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 index 180cf36b1be..0426394d32a 100644 --- 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 @@ -61,6 +61,8 @@ final class TagLibraryIndexFiles { static final String DYNAMIC_NAMESPACES_KEY = 'dynamicTagNamespaces' + static final String UNQUALIFIED_KEY = 'unqualifiedTagCalls' + private TagLibraryIndexFiles() { } @@ -86,14 +88,17 @@ final class TagLibraryIndexFiles { * @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 */ - static void writeSettings(File destination, boolean strictTags, Set dynamicNamespaces) { + static void writeSettings(File destination, boolean strictTags, Set dynamicNamespaces, + boolean unqualifiedTagCalls = false) { 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" + "${STRICT_KEY}=${strictTags}\n" + + "${UNQUALIFIED_KEY}=${unqualifiedTagCalls}\n" new File(indexDirectory, SETTINGS_FILE).setText(text, StandardCharsets.UTF_8.name()) } } 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 index 1ae86b94c71..165d4c44c53 100644 --- 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 @@ -89,6 +89,7 @@ public final class TagLibraryIndex { 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"; /** * One index per class loader. A compilation gets a class loader of its own, so this is read once @@ -106,11 +107,12 @@ public final class TagLibraryIndex { private final Set dynamicNamespaces; private final Set incompleteNamespaces; private final boolean everythingIncomplete; + private final boolean unqualifiedCalls; private TagLibraryIndex(Map> byNamespace, Map> ambiguousByNamespace, Map> tagNamesByClass, boolean strict, Set dynamicNamespaces, Set incompleteNamespaces, - boolean everythingIncomplete) { + boolean everythingIncomplete, boolean unqualifiedCalls) { this.byNamespace = byNamespace; this.ambiguousByNamespace = ambiguousByNamespace; this.tagNamesByClass = tagNamesByClass; @@ -118,6 +120,7 @@ private TagLibraryIndex(Map> byNamespa this.dynamicNamespaces = dynamicNamespaces; this.incompleteNamespaces = incompleteNamespaces; this.everythingIncomplete = everythingIncomplete; + this.unqualifiedCalls = unqualifiedCalls; } /** @@ -151,7 +154,7 @@ public static TagLibraryIndex load(ClassLoader classLoader) { Map> byClass = new TreeMap<>(); if (loader == null) { return new TagLibraryIndex(merged, ambiguous, byClass, false, Collections.emptySet(), - Collections.emptySet(), false); + Collections.emptySet(), false, false); } // 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 @@ -214,6 +217,7 @@ public static TagLibraryIndex load(ClassLoader classLoader) { } Properties settings = readSettings(loader); boolean strict = Boolean.parseBoolean(settings.getProperty(STRICT_KEY, "false")); + boolean unqualified = Boolean.parseBoolean(settings.getProperty(UNQUALIFIED_KEY, "false")); Set dynamic = new TreeSet<>(); for (String namespace : settings.getProperty(DYNAMIC_NAMESPACES_KEY, "").split(",")) { String trimmed = namespace.trim(); @@ -238,7 +242,7 @@ public static TagLibraryIndex load(ClassLoader classLoader) { } return new TagLibraryIndex(merged, ambiguous, byClass, strict, Collections.unmodifiableSet(dynamic), Collections.unmodifiableSet(incomplete), - allIncomplete); + allIncomplete, unqualified); } private static Set urls(ClassLoader loader, String location) { @@ -416,6 +420,21 @@ public boolean isClassDescribed(String tagLibraryClassName) { * * @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; + } + public boolean isStrict() { return strict; } 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 index 03eba2398c6..379deb4c233 100644 --- 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 @@ -296,6 +296,14 @@ private String unqualifiedNamespaceOf(MethodCallExpression call, String tagName) // 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; } 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 index 9cbc0ef7f52..fe48f505da9 100644 --- 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 @@ -131,8 +131,8 @@ class CompiledTagCallBytecodeSpec extends Specification { references(compiled, 'ComputedAttrsTagLib') } - void 'an unqualified call to a known tag is compiled as an invocation'() { - when: 'nothing in the tag library answers to the name, so it reaches a tag' + 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 @@ -144,8 +144,8 @@ class CompiledTagCallBytecodeSpec extends Specification { } ''', 'UnqualifiedCallerTagLib') - then: - references(compiled, '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'() { 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 index 255a7ca2810..2f8f83b4829 100644 --- 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 @@ -121,8 +121,8 @@ class GroovyMethodNameCollisionSpec extends Specification { references(compiled) } - void 'an unqualified call to a tag with no Groovy method of that name is still rewritten'() { - when: 'nothing else answers to the name' + 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 @@ -136,10 +136,60 @@ class GroovyMethodNameCollisionSpec extends Specification { } ''', 'GreetingCaller', 'demo') - then: 'so reserving Groovy\'s own names has not switched unqualified rewriting off' + 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') } From 95955df6bf3808a8513b4dd58a258a092eb00224 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Wed, 19 Aug 2026 13:25:29 -0700 Subject: [PATCH 65/74] Pin the unqualified setting on both sides of the module boundary --- .../views/gsp/TagLibraryIndexFilesSpec.groovy | 17 +++++++++++++++-- .../taglib/index/TagLibraryIndexSpec.groovy | 1 + 2 files changed, 16 insertions(+), 2 deletions(-) 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 index 6ebfc1250cd..46b2c33adc3 100644 --- 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 @@ -52,6 +52,19 @@ class TagLibraryIndexFilesSpec extends Specification { expect: TagLibraryIndexFiles.STRICT_KEY == 'strictTags' TagLibraryIndexFiles.DYNAMIC_NAMESPACES_KEY == 'dynamicTagNamespaces' + TagLibraryIndexFiles.UNQUALIFIED_KEY == 'unqualifiedTagCalls' + } + + 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'() { @@ -59,11 +72,11 @@ class TagLibraryIndexFilesSpec extends Specification { File destination = Files.createDirectory(tempDir.resolve('out')).toFile() when: - TagLibraryIndexFiles.writeSettings(destination, true, ['zeta', 'alpha'] as Set) + TagLibraryIndexFiles.writeSettings(destination, true, ['zeta', 'alpha'] as Set, true) 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\n' + 'dynamicTagNamespaces=alpha,zeta\nstrictTags=true\nunqualifiedTagCalls=true\n' } void 'clearing removes descriptors but keeps the settings beside them'() { 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 index 6d5aeeb5076..7c4bc92ff69 100644 --- 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 @@ -43,6 +43,7 @@ class TagLibraryIndexSpec extends Specification { TagLibraryIndex.SETTINGS_LOCATION == 'META-INF/grails/taglibs/compile-settings.properties' TagLibraryIndex.STRICT_KEY == 'strictTags' TagLibraryIndex.DYNAMIC_NAMESPACES_KEY == 'dynamicTagNamespaces' + TagLibraryIndex.UNQUALIFIED_KEY == 'unqualifiedTagCalls' } void 'tag libraries in separate jars merge into one namespace'() { From 045868cd8178bdab9b1312987ab7966686ac3adf Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Wed, 19 Aug 2026 13:38:58 -0700 Subject: [PATCH 66/74] Check a real build compiles a convention controller's tag call Every other assertion of this compiles a source in isolation, which shows the transform works but not that a project reaches it: the index has to be generated, packaged, put on the compile classpath and read, and the rewriting has to run after the trait that lets the class call tags. The spec that did cover the convention path drove it by writing a source into a temporary grails-app/controllers directory, and passed on macOS while failing on Linux and Windows - recognising a controller by its location depends on where the compilation happens, not on what is being compiled. Reading the class file a real build produced has no such dependence, so this answers the same question wherever CI runs it. --- .../functionaltests/IncludesController.groovy | 13 ++++ .../CompiledTagCallSpec.groovy | 65 +++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 grails-test-examples/app1/src/test/groovy/functionaltests/CompiledTagCallSpec.groovy 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 } + } +} From 12985ef89a187b2c4dbc1c0356524400763efd1a Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Wed, 19 Aug 2026 13:51:48 -0700 Subject: [PATCH 67/74] Record a tag name, not how it was written Kind was written into every descriptor as name:KIND, parsed back out and exposed as isBindable, and nothing ever asked. A closure tag and a method tag are dispatched the same way - by name, when the call runs - so no decision turned on it, and the javadoc claiming it decided whether a call could be resolved was simply wrong. Dropping it takes the encoding out of the format, the enum and the accessor out of the API, and the precedence rule out of the walk, which now just collects names. FORMAT_VERSION is what makes this reversible: the distinction can come back when something needs it. --- .../theWebLayer/gsp/taglibs/compiledTags.adoc | 5 +-- .../core/gsp/DefaultGrailsTagLibClass.java | 2 +- .../taglib/discovery/TagDiscoveryRules.java | 27 +++++--------- .../discovery/TagLibraryAstDiscovery.java | 9 ++--- .../grails/taglib/index/TagLibraryIndex.java | 15 +------- .../taglib/index/TagLibraryIndexEntry.java | 36 +------------------ .../taglib/index/TagLibraryIndexWriter.java | 31 +--------------- .../taglib/index/TagLibraryIndexSpec.groovy | 12 +++---- .../discovery/TagSetAgreementSpec.groovy | 23 +++++------- .../index/SingleIndexProducerSpec.groovy | 2 +- .../SourceResolvedIndexGeneratorSpec.groovy | 8 ++--- .../index/TagLibraryIndexGeneratorSpec.groovy | 6 ++-- 12 files changed, 41 insertions(+), 135 deletions(-) diff --git a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc index 67b19a90870..2cb8744f3eb 100644 --- a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc +++ b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc @@ -57,8 +57,9 @@ Closure hello = { Map attrs -> 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. Compiling a tag -library that declares one produces a warning naming the tag. +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 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 e172dc781df..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 @@ -79,7 +79,7 @@ public DefaultGrailsTagLibClass(Class clazz) { // 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)).keySet()); + 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/discovery/TagDiscoveryRules.java b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/discovery/TagDiscoveryRules.java index e49434d02a6..a39f1336332 100644 --- 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 @@ -18,13 +18,9 @@ */ package org.grails.taglib.discovery; -import java.util.HashSet; -import java.util.LinkedHashMap; -import java.util.Map; +import java.util.LinkedHashSet; import java.util.Set; -import org.grails.taglib.index.TagLibraryIndexEntry; - /** * Decides whether a method is a tag. * @@ -100,26 +96,21 @@ public static Set getFrameworkMethodNames() { * 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. * - *

Where a name is declared both ways the closure wins, and where it is declared as a closure - * more than once the nearest declaration wins, which is the order dispatch resolves them in. - * * @param view the tag library, from a syntax tree or from a compiled class - * @return each tag mapped to how it is implemented + * @return every tag name the library declares */ - public static Map findTags(TagLibraryView view) { - Map tags = new LinkedHashMap<>(); + public static Set findTags(TagLibraryView view) { + Set tags = new LinkedHashSet<>(); for (TagMethodView method : view.declaredMethods()) { if (isTagMethod(method)) { - tags.put(method.getName(), TagLibraryIndexEntry.Kind.METHOD); + tags.add(method.getName()); } } - Set claimed = new HashSet<>(); + // 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()) { - for (String name : current.declaredClosureFieldNames()) { - if (claimed.add(name)) { - tags.put(name, TagLibraryIndexEntry.Kind.LEGACY_CLOSURE); - } - } + tags.addAll(current.declaredClosureFieldNames()); } return tags; } 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 index efb5c55fe25..7957d895149 100644 --- 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 @@ -18,7 +18,7 @@ */ package org.grails.taglib.discovery; -import java.util.Map; +import java.util.Set; import groovy.lang.Closure; import org.codehaus.groovy.ast.ClassHelper; @@ -27,8 +27,6 @@ import org.codehaus.groovy.ast.expr.ConstantExpression; import org.codehaus.groovy.ast.expr.Expression; -import org.grails.taglib.index.TagLibraryIndexEntry; - /** * Reads a tag library's namespace and tag names from its syntax tree. * @@ -80,10 +78,9 @@ public static String resolveNamespace(ClassNode classNode) { /** * @param classNode the tag library * @param parameterNamesRetained whether this compilation writes parameter names into the class file - * @return each tag mapped to how it is implemented, so that a caller can tell a tag it can bind to - * from one it must dispatch dynamically + * @return every tag name the library declares */ - public static Map findTags(ClassNode classNode, + 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/index/TagLibraryIndex.java b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/index/TagLibraryIndex.java index 165d4c44c53..5cd26d846a0 100644 --- 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 @@ -184,19 +184,6 @@ public static TagLibraryIndex load(ClassLoader classLoader) { if (trimmed.isEmpty()) { continue; } - // Recorded as "name:KIND"; an unrecognised kind is treated as the dynamic one so that a - // descriptor from a later version cannot cause a call to be bound wrongly. - int separator = trimmed.lastIndexOf(':'); - String tagName = separator > 0 ? trimmed.substring(0, separator) : trimmed; - TagLibraryIndexEntry.Kind kind = TagLibraryIndexEntry.Kind.LEGACY_CLOSURE; - if (separator > 0) { - try { - kind = TagLibraryIndexEntry.Kind.valueOf(trimmed.substring(separator + 1)); - } catch (IllegalArgumentException unknownKind) { - kind = TagLibraryIndexEntry.Kind.LEGACY_CLOSURE; - } - } - trimmed = tagName; // 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. @@ -212,7 +199,7 @@ public static TagLibraryIndex load(ClassLoader classLoader) { continue; } tagsForNamespace.put(trimmed, - new TagLibraryIndexEntry(namespace, trimmed, className, kind, true)); + new TagLibraryIndexEntry(namespace, trimmed, className, true)); } } Properties settings = readSettings(loader); 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 index ae5f829b899..3103a2f9cfa 100644 --- 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 @@ -24,43 +24,9 @@ * @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 kind how the tag is implemented * @param acceptsBody whether the tag can be called with a body * @since 8.0.0 */ public record TagLibraryIndexEntry(String namespace, String tagName, String tagLibraryClassName, - Kind kind, boolean acceptsBody) { - - /** - * How a tag is implemented. - * - *

No call-site decision turns on this. A resolved call is compiled into an invocation that - * selects the tag by name when it runs, and a closure answers to a name as readily as a method - * does, so both forms are compiled the same way. - * - *

It is recorded because it is the difference between a tag a caller could one day bind to a - * signature and one that could never carry a signature to bind to. Binding to a specific method - * is not part of this release; recording the distinction now means the descriptor format does not - * have to change when it is. - */ - public enum Kind { - - /** - * A method, which carries a signature. - */ - METHOD, - - /** - * A {@code Closure} field, the deprecated form, which carries none. - */ - LEGACY_CLOSURE - } - - /** - * @return true when the tag carries a signature a caller could be bound to. Not consulted when - * deciding whether to compile a call: see {@link Kind}. - */ - public boolean isBindable() { - return kind == Kind.METHOD; - } + boolean acceptsBody) { } 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 index 84eb9e2a073..46549bdb255 100644 --- 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 @@ -32,9 +32,7 @@ import java.nio.file.Files; import java.nio.file.StandardOpenOption; import java.util.Collection; -import java.util.Map; import java.util.Properties; -import java.util.TreeMap; import java.util.TreeSet; /** @@ -92,24 +90,6 @@ public static void clear(File outputDirectory) throws IOException { */ public static void write(File outputDirectory, String className, String namespace, Collection tagNames) throws IOException { - Map asMethods = new TreeMap<>(); - for (String tagName : tagNames) { - asMethods.put(tagName, TagLibraryIndexEntry.Kind.METHOD); - } - write(outputDirectory, className, namespace, asMethods); - } - - /** - * Writes the descriptor for a tag library, recording how each tag is implemented. - * - * @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 tags each tag mapped to how it is implemented - * @throws IOException if the descriptor cannot be written - */ - public static void write(File outputDirectory, String className, String namespace, - Map tags) throws IOException { if (outputDirectory == null || className == null || className.isEmpty() || namespace == null || namespace.isEmpty()) { return; @@ -125,16 +105,7 @@ public static void write(File outputDirectory, String className, String namespac 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. - // Recorded as "name:KIND" so that a caller can tell a tag it can bind to from one that has to - // be dispatched dynamically, without a second file or a nested format. - StringBuilder encoded = new StringBuilder(); - for (Map.Entry tag : new TreeMap<>(tags).entrySet()) { - if (encoded.length() > 0) { - encoded.append(','); - } - encoded.append(tag.getKey()).append(':').append(tag.getValue().name()); - } - descriptor.setProperty(TagLibraryIndex.TAGS_KEY, encoded.toString()); + descriptor.setProperty(TagLibraryIndex.TAGS_KEY, String.join(",", new TreeSet<>(tagNames))); store(new File(indexDirectory, className + ".properties"), descriptor); addToManifest(new File(indexDirectory, "index.properties"), className); 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 index 7c4bc92ff69..41a95f96c9b 100644 --- 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 @@ -151,7 +151,7 @@ class TagLibraryIndexSpec extends Specification { loader.close() } - void 'a closure based tag is recorded but is not bindable'() { + void 'a closure based tag is recorded like any other'() { given: Path jarPath = tempDir.resolve('legacy.jar') new JarOutputStream(Files.newOutputStream(jarPath)).withCloseable { jar -> @@ -160,7 +160,7 @@ class TagLibraryIndexSpec extends Specification { 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:METHOD,asClosure:LEGACY_CLOSURE\n').bytes) + 'namespace=legacy\ntags=asMethod,asClosure\n').bytes) jar.closeEntry() } URLClassLoader loader = loaderOver(jarPath) @@ -171,9 +171,9 @@ class TagLibraryIndexSpec extends Specification { then: 'both are known, so neither is reported as a misspelling' index.getTagNames('legacy') == ['asClosure', 'asMethod'] as Set - and: 'only the method form can be compiled into a direct invocation' - index.lookup('legacy', 'asMethod').isBindable() - !index.lookup('legacy', 'asClosure').isBindable() + 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() @@ -328,7 +328,7 @@ class TagLibraryIndexSpec extends Specification { tagLibs.each { String className, List namespaceAndTags -> jar.putNextEntry(new JarEntry(TagLibraryIndex.INDEX_LOCATION + className + '.properties')) String encodedTags = namespaceAndTags[1].split(',') - .collect { "${it}:METHOD" }.join(',') + .join(',') jar.write(("version=${TagLibraryIndex.FORMAT_VERSION}\nclass=${className}\n" + "namespace=${namespaceAndTags[0]}\ntags=${encodedTags}\n").bytes) jar.closeEntry() 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 index b7686a9fb9b..851b4649b37 100644 --- 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 @@ -23,7 +23,6 @@ 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 org.grails.taglib.index.TagLibraryIndexEntry import spock.lang.Specification import spock.lang.Unroll @@ -41,16 +40,16 @@ class TagSetAgreementSpec extends Specification { @Unroll void 'both views find the same tags when #description'() { when: - Map fromTree = fromTree(source, subject) + Set fromTree = fromTree(source, subject) and: - Map fromClass = fromClass(source, subject) + Set fromClass = fromClass(source, subject) then: 'neither side may know a tag the other does not' fromTree == fromClass and: - fromTree.keySet() == expected as Set + fromTree == expected as Set where: description | subject | expected | source @@ -65,24 +64,18 @@ class TagSetAgreementSpec extends Specification { 'a static closure is not a tag' | 'Subject' | [] | 'class Subject { static Closure notATag = { Map attrs -> } }' } - void 'an inherited closure tag is recorded as a legacy closure, not lost'() { + void 'an inherited closure tag is kept alongside the class own tags, not lost'() { when: - Map tags = fromTree(''' + 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: TagLibraryIndexEntry.Kind.METHOD, common: TagLibraryIndexEntry.Kind.LEGACY_CLOSURE] + tags == ['own', 'common'] as Set } - void 'a name declared both ways is the closure, as dispatch resolves it'() { - expect: 'methodMissingForTagLib reads the closure property before the tag method' - fromTree('class Subject { def both(Map attrs) { }\n Closure both = { Map attrs -> } }', 'Subject') - .get('both') == TagLibraryIndexEntry.Kind.LEGACY_CLOSURE - } - - private Map fromTree(String source, String subject) { + private Set fromTree(String source, String subject) { CompilerConfiguration configuration = new CompilerConfiguration() configuration.parameters = true CompilationUnit unit = new CompilationUnit(configuration) @@ -93,7 +86,7 @@ class TagSetAgreementSpec extends Specification { TagDiscoveryRules.findTags(new AstTagLibraryView(classNode, configuration.parameters)) } - private Map fromClass(String source, String subject) { + private Set fromClass(String source, String subject) { CompilerConfiguration configuration = new CompilerConfiguration() configuration.parameters = true GroovyClassLoader loader = new GroovyClassLoader(getClass().classLoader, configuration) 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 index c83a8ae7ba2..34ac938f6ae 100644 --- 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 @@ -100,7 +100,7 @@ class SingleIndexProducerSpec extends Specification { 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}:METHOD\n" + "namespace=${namespace}\ntags=${tag}\n" compileAgainst(generated, className) } 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 index c07e5ae1c30..60653a61a44 100644 --- 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 @@ -73,7 +73,7 @@ class SourceResolvedIndexGeneratorSpec extends Specification { then: descriptor('InjectingTagLib').namespace == 'injecting' - descriptor('InjectingTagLib').tags == 'listBooks:METHOD' + descriptor('InjectingTagLib').tags == 'listBooks' } void 'a namespace inherited from a base class this project declares is read, not guessed'() { @@ -123,7 +123,7 @@ class SourceResolvedIndexGeneratorSpec extends Specification { then: descriptor('CarryingTagLib').tags.split(',').toList().sort() == - ['goodbye:METHOD', 'hello:METHOD'] + ['goodbye', 'hello'] } void 'a parameter type this project declares is recognised as attributes when it is a Map'() { @@ -147,7 +147,7 @@ class SourceResolvedIndexGeneratorSpec extends Specification { generate() then: - descriptor('SubtypedTagLib').tags == 'show:METHOD' + descriptor('SubtypedTagLib').tags == 'show' } void 'a star import resolves to the type that exists rather than the first one tried'() { @@ -174,7 +174,7 @@ class SourceResolvedIndexGeneratorSpec extends Specification { then: descriptor('StarredTagLib').namespace == 'starred' - descriptor('StarredTagLib').tags == 'show:METHOD' + descriptor('StarredTagLib').tags == 'show' } void 'a misspelled type is not invented, and the tag library referring to it is left out'() { 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 index b44592d6c3e..4a064d46273 100644 --- 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 @@ -57,7 +57,7 @@ class TagLibraryIndexGeneratorSpec extends Specification { 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:LEGACY_CLOSURE,asMethod:METHOD' + descriptor('LegacyTagLib').tags == 'asClosure,asMethod' } void 'a tag library is described without being loaded or executed'() { @@ -78,7 +78,7 @@ class TagLibraryIndexGeneratorSpec extends Specification { then: 'its tags are described from the source alone' descriptor('ExplosiveTagLib').namespace == 'boom' - descriptor('ExplosiveTagLib').tags == 'alpha:METHOD,beta:METHOD' + descriptor('ExplosiveTagLib').tags == 'alpha,beta' } void 'a renamed tag library leaves nothing behind'() { @@ -149,7 +149,7 @@ class TagLibraryIndexGeneratorSpec extends Specification { then: 'the one that reads is described' descriptorFile('FineTagLib').exists() - descriptor('FineTagLib').tags == 'present:METHOD' + descriptor('FineTagLib').tags == 'present' and: 'the one that does not is left out here, and describes itself when it is compiled' !descriptorFile('UnresolvableTagLib').exists() From e1af1b99ef111d1724e544623bf08aaf7f76dae4 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Wed, 19 Aug 2026 14:02:56 -0700 Subject: [PATCH 68/74] Check tags only in the namespaces this project describes Strict checking asked whether a tag was in the index, and the index holds what the tag libraries on the classpath described. For a namespace this project declares that is the whole answer. For any other it is not: a plugin built before descriptors existed contributes tags to g without one, as does one declaring its tag libraries by convention without the GSP Gradle plugin, and a tag library registered at runtime contributes more. Reporting a tag missing from such a namespace failed builds over correct code, which made strictTags unusable for g - the namespace it would matter most for. The generator already knows which namespaces it described, so the task records them beside the settings, which are not packaged, and reporting is limited to those. strictTags now catches a misspelling of your own tags in your own namespaces and never complains about a plugin's. --- .../theWebLayer/gsp/taglibs/compiledTags.adoc | 39 +++++++++---------- .../gsp/GenerateTagLibraryIndexTask.groovy | 3 +- .../views/gsp/TagLibraryIndexFiles.groovy | 33 +++++++++++++++- .../views/gsp/TagLibraryIndexFilesSpec.groovy | 20 +++++++++- .../grails/taglib/index/TagLibraryIndex.java | 31 +++++++++++++-- .../taglib/index/TagLibraryIndexSpec.groovy | 1 + .../compiler/CompiledTagCallRewriter.java | 7 ++++ .../taglib/GspStaticTagResolutionSpec.groovy | 18 +++++++-- 8 files changed, 120 insertions(+), 32 deletions(-) diff --git a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc index 2cb8744f3eb..3c4ef0f6f07 100644 --- a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc +++ b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc @@ -223,27 +223,24 @@ metaprogramming. Both settings are read from the build, not from a system property, so changing either recompiles what depends on it. -===== When strict checking can be used - -Strict checking asks whether a tag is in the index, and the index holds what the tag libraries on the -classpath described. It cannot tell a namespace that is fully described from one that several jars -contribute to and only some of them described. - -That matters most for `g`. Grails describes its own tag libraries, so `g` is always partly described; -a plugin built against an earlier version of Grails, or one that declares its tag libraries by -convention without applying the GSP Gradle plugin, contributes tags to `g` with no description. Under -`strictTags` every call to one of those tags is reported, and the code is correct. - -So enable `strictTags` when every tag library the application uses is described — its own, and its -plugins'. Where one is not, the only remedy is to name its namespace in `dynamicTagNamespaces`, which -switches compile-time resolution off for that namespace entirely. For `g` that means giving up the -feature where it is worth the most, so an application depending on an undescribed third-party tag -library in `g` is better off leaving `strictTags` alone and keeping the default, which reports nothing -and resolves what it can. - -An index generated from source before compilation records what it could not describe, and no tag in a -namespace it failed to read completely is ever reported. That covers what one build could not read -about itself; it cannot cover what another project never wrote down. +===== 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 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 index 6d20a267256..8c20c33ec0f 100644 --- 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 @@ -200,6 +200,7 @@ abstract class GenerateTagLibraryIndexTask extends DefaultTask { File settingsDestination = settingsDirectory.present ? settingsDirectory.get().asFile : destination settingsDestination.mkdirs() TagLibraryIndexFiles.writeSettings(settingsDestination, strictTags.getOrElse(false), - dynamicTagNamespaces.getOrElse([] as Set), unqualifiedTagCalls.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/TagLibraryIndexFiles.groovy b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/views/gsp/TagLibraryIndexFiles.groovy index 0426394d32a..d8c5373461e 100644 --- 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 @@ -63,9 +63,36 @@ final class TagLibraryIndexFiles { 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. @@ -89,16 +116,18 @@ final class TagLibraryIndexFiles { * @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) { + 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" + "${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/TagLibraryIndexFilesSpec.groovy b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/views/gsp/TagLibraryIndexFilesSpec.groovy index 46b2c33adc3..57a18131484 100644 --- 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 @@ -53,6 +53,7 @@ class TagLibraryIndexFilesSpec extends Specification { 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'() { @@ -72,11 +73,26 @@ class TagLibraryIndexFilesSpec extends Specification { File destination = Files.createDirectory(tempDir.resolve('out')).toFile() when: - TagLibraryIndexFiles.writeSettings(destination, true, ['zeta', 'alpha'] as Set, true) + 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' + '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'() { 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 index 5cd26d846a0..08aa3c885e7 100644 --- 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 @@ -90,6 +90,7 @@ public final class TagLibraryIndex { 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 @@ -108,11 +109,12 @@ public final class TagLibraryIndex { 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) { + boolean everythingIncomplete, boolean unqualifiedCalls, Set localNamespaces) { this.byNamespace = byNamespace; this.ambiguousByNamespace = ambiguousByNamespace; this.tagNamesByClass = tagNamesByClass; @@ -121,6 +123,7 @@ private TagLibraryIndex(Map> byNamespa this.incompleteNamespaces = incompleteNamespaces; this.everythingIncomplete = everythingIncomplete; this.unqualifiedCalls = unqualifiedCalls; + this.localNamespaces = localNamespaces; } /** @@ -154,7 +157,7 @@ public static TagLibraryIndex load(ClassLoader classLoader) { Map> byClass = new TreeMap<>(); if (loader == null) { return new TagLibraryIndex(merged, ambiguous, byClass, false, Collections.emptySet(), - Collections.emptySet(), false, false); + 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 @@ -205,6 +208,13 @@ public static TagLibraryIndex load(ClassLoader classLoader) { 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(); @@ -229,7 +239,7 @@ public static TagLibraryIndex load(ClassLoader classLoader) { } return new TagLibraryIndex(merged, ambiguous, byClass, strict, Collections.unmodifiableSet(dynamic), Collections.unmodifiableSet(incomplete), - allIncomplete, unqualified); + allIncomplete, unqualified, Collections.unmodifiableSet(local)); } private static Set urls(ClassLoader loader, String location) { @@ -422,6 +432,21 @@ 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; } 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 index 41a95f96c9b..62007aaad9c 100644 --- 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 @@ -44,6 +44,7 @@ class TagLibraryIndexSpec extends Specification { 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'() { 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 index 379deb4c233..2ebc1b3368b 100644 --- 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 @@ -472,6 +472,13 @@ 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 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 index 3254e275b71..4b6b2c43a15 100644 --- 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 @@ -106,8 +106,8 @@ class GspStaticTagResolutionSpec extends Specification { } void 'an unrecognised tag fails compilation when the build declares its tags complete'() { - given: - GroovyPagesTemplateEngine strict = engineFor('strictTags=true\n') + 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: @@ -119,6 +119,18 @@ class GspStaticTagResolutionSpec extends Specification { 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') @@ -133,7 +145,7 @@ class GspStaticTagResolutionSpec extends Specification { void 'a tag written as markup is checked against the same descriptions'() { given: - GroovyPagesTemplateEngine strict = engineFor('strictTags=true\n') + GroovyPagesTemplateEngine strict = engineFor('strictTags=true\nlocalNamespaces=g\n') String template = '''<%@ page compileStatic="true" %>''' when: From cb57d22e3209cf85dbc924209cfbc4e8995da5af Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Wed, 19 Aug 2026 14:24:27 -0700 Subject: [PATCH 69/74] Do not describe a class an application could never register An abstract class kept beside the tag libraries that share it was described, with its methods filed under the default namespace. Artefact handling never registers one, and a subclass does not inherit its methods as tags, so those tags existed nowhere: a call to one compiled into an invocation that throws when it runs, and a misspelling matching such a name stopped being reported. Skip abstract class nodes in the generator and in the self-describing path, which covers traits and interfaces too. Also suppress the URL constructor deprecation deliberately rather than leave the note the last round removed: URI.resolve cannot resolve a relative name against an opaque jar: URI, so the constructor stays. --- .../index/TagLibraryIndexGenerator.java | 19 +++++++++++- .../TagLibArtefactTypeAstTransformation.java | 5 ++++ .../index/TagLibraryIndexGeneratorSpec.groovy | 30 +++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) 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 index 1f921c78b34..f285132d4bf 100644 --- 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 @@ -163,7 +163,8 @@ public static void generate(List sourceDirs, List resolutionRoots, F describable.add(source.getAbsolutePath()); } for (ClassNode classNode : parse(sources, roots, parameterNamesRetained, encoding, skipped)) { - if (!isTagLibrary(classNode) || !wasAskedFor(classNode, describable)) { + if (!isTagLibrary(classNode) || !isRegistrable(classNode) || + !wasAskedFor(classNode, describable)) { continue; } String namespace = TagLibraryAstDiscovery.resolveNamespace(classNode); @@ -389,6 +390,22 @@ private static boolean isTagLibrary(ClassNode classNode) { 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 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 224b79d543a..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 @@ -65,6 +65,11 @@ protected String resolveArtefactType(SourceUnit sourceUnit, AnnotationNode annot * 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; 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 index 4a064d46273..eda7e2286df 100644 --- 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 @@ -267,4 +267,34 @@ class TagLibraryIndexGeneratorSpec extends Specification { !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') + } + } From 8ea3a9aa3e7217958eeac7e48d758b163a81ca7e Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Wed, 19 Aug 2026 16:02:37 -0700 Subject: [PATCH 70/74] Say why the deprecated URL constructor stays new URL(URL, String) is deprecated since JDK 20, so compiling this module printed a deprecation note. The constructor is nonetheless the tool that works: a manifest inside a jar is addressed by an opaque jar: URI, which URI.resolve cannot resolve a relative name against, and round-tripping the URL through URI breaks on characters ClassLoader.getResources does not encode. Suppress it deliberately and record the reason, so the module compiles quietly without the reason being lost with it. --- .../groovy/org/grails/taglib/index/TagLibraryIndex.java | 9 +++++++++ 1 file changed, 9 insertions(+) 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 index 08aa3c885e7..213930e368e 100644 --- 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 @@ -299,10 +299,19 @@ private static Set listDescriptors(ClassLoader loader) { } /** + * 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); From 645241d93074c0744cd6694882d62652d2067c76 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Wed, 19 Aug 2026 16:07:35 -0700 Subject: [PATCH 71/74] Say that a namespace is not checked against a closure's delegate --- .../src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc index 3c4ef0f6f07..577d234e1b8 100644 --- a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc +++ b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/compiledTags.adoc @@ -154,6 +154,12 @@ grails { 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 From cb99cc8fc114e54dd6cb81015af28ac41ec35a3d Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Thu, 20 Aug 2026 10:54:59 -0700 Subject: [PATCH 72/74] Read a class for tag calls once its traits have been applied The rewrite decides whether a class can call tags by looking for the trait that gives it the ability. Ordering that after trait injection by priority holds only among transforms that declare one, and a trait can arrive from a local transform, which is applied after every global transform has run. A controller compiled from the conventional directory was read after it carried the trait; the same source compiled from elsewhere was read before. Which of the two happened for a given class also varied by platform, so the rewrite was applied on macOS and skipped on Linux for the same controller. Reading the class in a later phase makes the question decidable: every trait has been applied by then, whichever transform supplied it. The priority stays as a second guarantee for anything ordered within the phase. This also removes the limitation the rewrite carried, that a controller declared by annotation outside grails-app/controllers kept the dispatched call rather than the direct one. Its test now pins the rewrite instead of the limitation, and a new one pins the phase the correctness now rests on. --- .../traits/CompiledTagCallTransformation.groovy | 11 +++++++---- .../CompiledTagCallTransformationOrderSpec.groovy | 10 ++++++++++ .../web/taglib/ControllerTagCallRewriteSpec.groovy | 10 +++++----- 3 files changed, 22 insertions(+), 9 deletions(-) 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 index 6f321a6e346..9a6c47a63f7 100644 --- a/grails-gsp/plugin/src/ast/groovy/grails/compiler/traits/CompiledTagCallTransformation.groovy +++ b/grails-gsp/plugin/src/ast/groovy/grails/compiler/traits/CompiledTagCallTransformation.groovy @@ -43,14 +43,17 @@ import org.grails.taglib.index.TagLibraryIndex * 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. + *

Runs in a later phase than trait injection, since whether a class can call tags is only settled + * once its traits have been applied. Ordering by priority within a phase was not enough: it holds for + * transforms that declare one, but a trait arriving from a local transform is applied after every + * global transform has run, so a class compiled from outside the conventional directory was read + * before it carried the trait, and the same source read as a convention class was read after. Waiting + * for the phase reads every class once it is whole, which is what the rewrite needs to decide. * * @since 8.0.0 */ @CompileStatic -@GroovyASTTransformation(phase = CompilePhase.CANONICALIZATION) +@GroovyASTTransformation(phase = CompilePhase.INSTRUCTION_SELECTION) class CompiledTagCallTransformation implements ASTTransformation, TransformWithPriority { private static final ClassNode TAG_LIBRARY_INVOKER = ClassHelper.make(TagLibraryInvoker) 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 index 5c18bd299bf..8b55daf46f3 100644 --- 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 @@ -20,6 +20,8 @@ package org.grails.web.taglib import grails.compiler.traits.CompiledTagCallTransformation import org.apache.grails.common.compiler.GroovyTransformOrder +import org.codehaus.groovy.control.CompilePhase +import org.codehaus.groovy.transform.GroovyASTTransformation import org.codehaus.groovy.transform.TransformWithPriority import spock.lang.Specification @@ -55,4 +57,12 @@ class CompiledTagCallTransformationOrderSpec extends Specification { rewriting == GroovyTransformOrder.COMPILED_TAG_CALL_ORDER rewriting < GroovyTransformOrder.COMMAND_FACTORIES_ORDER } + + void 'it runs in a later phase than the transforms that inject artefact traits'() { + given: 'a trait can arrive from a local transform, which no priority within a phase orders' + GroovyASTTransformation declared = CompiledTagCallTransformation.getAnnotation(GroovyASTTransformation) + + expect: 'so the class is read in a phase by which every trait has been applied' + declared.phase() == CompilePhase.INSTRUCTION_SELECTION + } } 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 index 1510baf5b50..8aca1479ebe 100644 --- 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 @@ -75,7 +75,7 @@ class ControllerTagCallRewriteSpec extends Specification { !references(compiled, 'org/grails/taglib/CompiledTagInvocation') } - void 'a controller declared by annotation outside that directory is not rewritten'() { + void 'a controller declared by annotation outside that directory is rewritten too'() { when: 'the trait arrives from a local transform, which runs after every global one' byte[] compiled = compileAt('src/main/groovy/demo', ''' package demo @@ -90,11 +90,11 @@ class ControllerTagCallRewriteSpec extends Specification { } ''', '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') + then: 'the rewrite reads a class the traits have already been applied to, so where the source ' + + 'sat makes no difference to it' + references(compiled, 'org/grails/taglib/CompiledTagInvocation') - and: 'and it really is a controller, so the difference is the source layout alone' + and: 'and it really is a controller' references(compiled, 'grails/artefact/gsp/TagLibraryInvoker') } From 0f59eafd5758beecfcce8d472e343e65830fee09 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Thu, 20 Aug 2026 11:15:40 -0700 Subject: [PATCH 73/74] Cover the controller the rewrite decides by its directory The case was asked for in review and dropped as platform-dependent, on the reading that recognising a controller by its location turns on where the compilation happens. That reading was wrong: the trait assertion holds on every platform, so the injector does recognise a controller compiled into a temporary directory. What varied was the rewrite, for the reason the previous commit fixed. This is the shape that carried the bug, so it is worth a test that runs in a second rather than only a functional one that needs a whole build. --- .../ControllerTagCallRewriteSpec.groovy | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) 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 index 8aca1479ebe..8fbc375b430 100644 --- 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 @@ -98,6 +98,25 @@ class ControllerTagCallRewriteSpec extends Specification { references(compiled, 'grails/artefact/gsp/TagLibraryInvoker') } + void 'a controller recognised by its directory is rewritten'() { + when: 'no annotation, so the trait comes from the artefact injector recognising the location' + byte[] compiled = compileAt('grails-app/controllers/demo', ''' + package demo + + class ConventionController { + def index() { + g.createLink(controller: 'book') + } + } + ''', 'ConventionController', 'demo') + + then: 'which is the shape the rewrite has to handle, and the one it used to get wrong' + references(compiled, 'org/grails/taglib/CompiledTagInvocation') + + and: 'the trait it decides on really did arrive' + references(compiled, 'grails/artefact/gsp/TagLibraryInvoker') + } + private static boolean references(byte[] classBytes, String internalName) { new String(classBytes, 'ISO-8859-1').contains(internalName) } From 1d82b099411439b151925a1b839cd5a5ff34aa66 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Thu, 20 Aug 2026 11:43:25 -0700 Subject: [PATCH 74/74] Report an unregistered tag as the dynamic path reported it The index describes what a tag library declares when it is compiled, which is not what a running application registers: a plugin can be excluded, a tag library can be named in nonEnhancedTagLibClasses, and a unit test can mock some tag libraries and not others. A call resolved against the index reached TagOutput directly and raised GrailsTagException there, where the same call dispatched dynamically raised MissingMethodException. Code catching that around a tag call, or probing with respondsTo, saw the difference. An unregistered tag now goes back through the namespace dispatcher, which is the dispatch the call would have had. That reports what it always reported, including the type it names, rather than a second copy of the rule here. --- .../grails/taglib/CompiledTagInvocation.java | 35 +++++++++++++++++++ .../taglib/CompiledTagInvocationSpec.groovy | 17 +++++++++ 2 files changed, 52 insertions(+) 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 index e2c6317070a..beba0b14963 100644 --- 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 @@ -23,6 +23,7 @@ import java.util.Map; import groovy.lang.Closure; +import groovy.lang.MissingMethodException; import org.grails.taglib.encoder.OutputContext; import org.grails.taglib.encoder.OutputContextLookupHelper; @@ -86,9 +87,43 @@ public static Object invoke(TagLibraryLookup lookup, String namespace, String ta // 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; + if (lookup.lookupTagLibrary(namespace, tagName) == null) { + return dispatchUnregistered(lookup, namespace, tagName, attributes, tagBody); + } return TagOutput.captureTagOutput(lookup, namespace, tagName, attributes, tagBody, outputContext); } + /** + * Hands a tag the index knows but the running application has not registered back to the dispatch + * that would have run had the call never been resolved. + * + *

The index describes what a tag library declares when it is compiled, which is not the same + * question as what a running application registers: a plugin can be excluded, a tag library can be + * named in {@code nonEnhancedTagLibClasses}, and a unit test can mock some tag libraries and not + * others. The dynamic path reported that as a {@link MissingMethodException}, and code written + * around a tag call catches it or probes with {@code respondsTo}, so resolving the call must not + * turn it into something else. Dispatching through the namespace rather than raising the exception + * here also keeps the type it names the one that path named. + */ + private static Object dispatchUnregistered(TagLibraryLookup lookup, String namespace, String tagName, + Map attrs, Object body) { + Object[] arguments; + if (body != null) { + arguments = new Object[] {attrs, body}; + } + else if (!attrs.isEmpty()) { + arguments = new Object[] {attrs}; + } + else { + arguments = EMPTY_ARGUMENTS; + } + NamespacedTagDispatcher dispatcher = lookup.lookupNamespaceDispatcher(namespace); + if (dispatcher == null) { + throw new MissingMethodException(tagName, CompiledTagInvocation.class, arguments); + } + return dispatcher.invokeMethod(tagName, arguments); + } + /** * Invokes a tag with whatever arguments the call was written with. * 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 index 8f5517ecbff..7c980d588f2 100644 --- 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 @@ -70,6 +70,23 @@ class CompiledTagInvocationSpec extends Specification implements TagLibUnitTest< e.message.contains('link') } + void 'a tag the running application has not registered is reported as a missing method'() { + when: 'the index described the namespace, so the call was resolved, but nothing registers this tag' + CompiledTagInvocation.invoke(lookup, 'g', 'noSuchTagAnywhere', [:], null) + + then: 'which is what dispatching the call dynamically reported, and what callers catch' + MissingMethodException e = thrown() + e.method == 'noSuchTagAnywhere' + } + + void 'a tag missing from a namespace nothing registers is reported the same way'() { + when: + CompiledTagInvocation.invokeArguments(lookup, 'noSuchNamespace', 'anyTag', [a: 1]) + + then: + thrown(MissingMethodException) + } + 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']