codeconsole commented on code in PR #16134:
URL: https://github.com/apache/grails-core/pull/16134#discussion_r3793083792


##########
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(

Review Comment:
   You're right that it's a behaviour change to a public trait. 
`TagLibraryInvokerDispatchSpec` now covers a method-declared tag called 
unqualified through `methodMissing` from a class carrying the trait, asserting 
the captured output is returned rather than the method's own return value, and 
it's in `upgrading80x.adoc` with a before/after.



##########
grails-gsp/core/src/main/groovy/org/grails/gsp/GroovyPage.java:
##########
@@ -296,6 +310,23 @@ public Object getProperty(String property) {
         return resolveProperty(property);
     }
 
+    /**
+     * Resolves a tag called without a namespace, as {@code ${message(code: 
'x')}} is.
+     *
+     * <p>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,

Review Comment:
   I could not reproduce this. With a verified-null lookup and the guard 
removed, `MissingMethodException` is thrown, not an NPE — the dynamic call on 
the null receiver yields no tag library rather than throwing, so it falls 
through to the `MissingMethodException` at the end of `methodMissingForTagLib`.
   
   Worth flagging what misled me first time: my initial spec passed against the 
unguarded code, and the exception carried `arguments == [null]`, which comes 
from `makeObjectArray` — so the guard was never reached. Property access on a 
`GroovyPage` routes through its overridden `getProperty`, so 
`page.tagLibraryLookup` wasn't reading the field at all.
   
   I kept a guard anyway, since the field is documented as null before 
initialisation and relying on Groovy's null-receiver behaviour isn't a 
contract, but reworded so it doesn't claim to fix a crash. 
`GroovyPageMethodMissingSpec` pins the behaviour. Happy to drop it.



##########
grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/index/TagLibraryIndex.java:
##########
@@ -0,0 +1,485 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.grails.taglib.index;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Properties;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.TreeSet;
+import java.util.WeakHashMap;
+
+/**
+ * The set of tag libraries and tag names known at compile time.
+ *
+ * <p>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.
+ *
+ * <p>Reading the index answers "which tags exist in namespace x" without 
loading or reflecting over a
+ * single tag library class, which is what allows GSP expressions to be 
resolved when a page is
+ * compiled rather than dispatched dynamically when it renders.
+ *
+ * @since 8.0.0
+ */
+public final class TagLibraryIndex {
+
+    /**
+     * Classpath directory holding one descriptor per compiled tag library.
+     */
+    public static final String INDEX_LOCATION = "META-INF/grails/taglibs/";
+
+    /**
+     * Descriptor format this build writes and understands. A descriptor 
carrying anything else was
+     * produced by a different version of Grails and is ignored, so its tags 
resolve dynamically rather
+     * than being read under the wrong set of rules.
+     */
+    public static final int FORMAT_VERSION = 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";
+
+    /**
+     * What the index could not describe, written by whatever produced it.
+     *
+     * <p>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";

Review Comment:
   Documented. The guide now has a "when strict checking can be used" section 
saying plainly that a namespace only some of its jars described cannot be 
checked, that this is the normal state of `g`, and that an application 
depending on an undescribed third-party tag library in `g` is better off 
leaving the default alone than naming `g` in `dynamicTagNamespaces`.



##########
grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/index/TagLibraryIndex.java:
##########
@@ -0,0 +1,485 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.grails.taglib.index;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Properties;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.TreeSet;
+import java.util.WeakHashMap;
+
+/**
+ * The set of tag libraries and tag names known at compile time.
+ *
+ * <p>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.
+ *
+ * <p>Reading the index answers "which tags exist in namespace x" without 
loading or reflecting over a
+ * single tag library class, which is what allows GSP expressions to be 
resolved when a page is
+ * compiled rather than dispatched dynamically when it renders.
+ *
+ * @since 8.0.0
+ */
+public final class TagLibraryIndex {
+
+    /**
+     * Classpath directory holding one descriptor per compiled tag library.
+     */
+    public static final String INDEX_LOCATION = "META-INF/grails/taglibs/";
+
+    /**
+     * Descriptor format this build writes and understands. A descriptor 
carrying anything else was
+     * produced by a different version of Grails and is ignored, so its tags 
resolve dynamically rather
+     * than being read under the wrong set of rules.
+     */
+    public static final int FORMAT_VERSION = 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";
+
+    /**
+     * What the index could not describe, written by whatever produced it.
+     *
+     * <p>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";
+
+    /**
+     * 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<ClassLoader, TagLibraryIndex> BY_CLASS_LOADER =
+            Collections.synchronizedMap(new WeakHashMap<>());
+
+    private final Map<String, Map<String, TagLibraryIndexEntry>> byNamespace;
+    private final Map<String, Set<String>> ambiguousByNamespace;
+    private final Map<String, Set<String>> tagNamesByClass;
+    private final boolean strict;
+    private final Set<String> dynamicNamespaces;
+    private final Set<String> incompleteNamespaces;
+    private final boolean everythingIncomplete;
+
+    private TagLibraryIndex(Map<String, Map<String, TagLibraryIndexEntry>> 
byNamespace,
+            Map<String, Set<String>> ambiguousByNamespace, Map<String, 
Set<String>> tagNamesByClass,
+            boolean strict, Set<String> dynamicNamespaces, Set<String> 
incompleteNamespaces,
+            boolean everythingIncomplete) {
+        this.byNamespace = byNamespace;
+        this.ambiguousByNamespace = ambiguousByNamespace;
+        this.tagNamesByClass = tagNamesByClass;
+        this.strict = strict;
+        this.dynamicNamespaces = dynamicNamespaces;
+        this.incompleteNamespaces = incompleteNamespaces;
+        this.everythingIncomplete = everythingIncomplete;
+    }
+
+    /**
+     * Reads the index for a class loader, reusing the one already read for it.
+     *
+     * <p>Reading walks every jar on the classpath, so a compiler that 
consults the index for each
+     * source file it compiles would walk it once per file. Use this from 
compilation; use
+     * {@link #load(ClassLoader)} where a fresh read is wanted.
+     *
+     * @param classLoader the loader to scan; when {@code null} the thread 
context loader is used
+     * @return the merged index, never {@code null}
+     */
+    public static TagLibraryIndex forClassLoader(ClassLoader classLoader) {
+        ClassLoader loader = classLoader != null ? classLoader : 
Thread.currentThread().getContextClassLoader();
+        if (loader == null) {
+            return load(null);
+        }
+        return BY_CLASS_LOADER.computeIfAbsent(loader, TagLibraryIndex::load);
+    }
+
+    /**
+     * Reads every tag library descriptor visible to the given class loader.
+     *
+     * @param classLoader the loader to scan; when {@code null} the thread 
context loader is used
+     * @return the merged index, never {@code null}
+     */
+    public static TagLibraryIndex load(ClassLoader classLoader) {
+        ClassLoader loader = classLoader != null ? classLoader : 
Thread.currentThread().getContextClassLoader();
+        Map<String, Map<String, TagLibraryIndexEntry>> merged = new 
TreeMap<>();
+        Map<String, Set<String>> ambiguous = new TreeMap<>();
+        Map<String, Set<String>> byClass = new TreeMap<>();
+        if (loader == null) {
+            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
+        // rather than by listing the directory.
+        for (URL url : listDescriptors(loader)) {
+            Properties properties = read(url);
+            if (properties == null) {
+                continue;
+            }
+            if 
(!String.valueOf(FORMAT_VERSION).equals(properties.getProperty(VERSION_KEY))) {
+                continue;
+            }
+            String namespace = properties.getProperty(NAMESPACE_KEY);
+            String className = properties.getProperty(CLASS_KEY);
+            String tags = properties.getProperty(TAGS_KEY, "");
+            if (namespace == null || namespace.isEmpty() || className == null 
|| className.isEmpty()) {
+                continue;
+            }
+            // Recorded from the descriptor rather than from its tags, so that 
a tag library declaring
+            // none of them is still known to have been described. Deciding 
that from the tags alone
+            // would have such a tag library described twice.
+            byClass.computeIfAbsent(className, k -> new TreeSet<>());
+            Map<String, TagLibraryIndexEntry> tagsForNamespace =
+                    merged.computeIfAbsent(namespace, k -> new TreeMap<>());
+            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;
+                // Recorded against the declaring class before ambiguity is 
considered, so that asking
+                // what one tag library declares is answered from its own 
descriptor and is unaffected
+                // by whether some other tag library happens to declare the 
same name.
+                byClass.computeIfAbsent(className, k -> new 
TreeSet<>()).add(trimmed);
+                TagLibraryIndexEntry existing = tagsForNamespace.get(trimmed);
+                if (existing != null && 
!existing.tagLibraryClassName().equals(className)) {
+                    // At runtime the tag library registered last wins, and 
registration order comes
+                    // from artefact scanning rather than from classpath 
order, so which of these two
+                    // will win cannot be known here. Resolving it either way 
risks compiling against
+                    // one implementation and dispatching to the other, so the 
tag is marked ambiguous
+                    // and left to runtime resolution.
+                    ambiguous.computeIfAbsent(namespace, k -> new 
TreeSet<>()).add(trimmed);
+                    continue;
+                }
+                tagsForNamespace.put(trimmed,
+                        new TagLibraryIndexEntry(namespace, trimmed, 
className, kind, true));
+            }
+        }
+        Properties settings = readSettings(loader);
+        boolean strict = Boolean.parseBoolean(settings.getProperty(STRICT_KEY, 
"false"));
+        Set<String> dynamic = new TreeSet<>();
+        for (String namespace : settings.getProperty(DYNAMIC_NAMESPACES_KEY, 
"").split(",")) {
+            String trimmed = namespace.trim();
+            if (!trimmed.isEmpty()) {
+                dynamic.add(trimmed);
+            }
+        }
+        Set<String> 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<URL> urls(ClassLoader loader, String location) {
+        Set<URL> found = new LinkedHashSet<>();
+        try {
+            Enumeration<URL> resources = loader.getResources(location);
+            while (resources.hasMoreElements()) {
+                found.add(resources.nextElement());
+            }
+        }
+        catch (IOException unreadable) {
+            return found;
+        }
+        return found;
+    }
+
+    /**
+     * Reads the settings the build states for this compilation. Only the 
project being compiled
+     * contributes them, so the first one found wins rather than several being 
merged.
+     */
+    private static Properties readSettings(ClassLoader loader) {
+        URL url = loader.getResource(SETTINGS_LOCATION);
+        if (url == null) {
+            return new Properties();
+        }
+        Properties settings = read(url);
+        return settings != null ? settings : new Properties();
+    }
+
+    private static Set<URL> listDescriptors(ClassLoader loader) {

Review Comment:
   Fixed — descriptor URLs are resolved against the manifest that names them 
(`new URL(manifest, className + ".properties")`) instead of asking the loader, 
so it's O(taglibs) rather than O(taglibs × classpath). The multi-jar merge 
cases in `TagLibraryIndexSpec` cover the jar-URL resolution.



##########
grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/index/TagLibraryIndexEntry.java:
##########
@@ -0,0 +1,57 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.grails.taglib.index;
+
+/**
+ * One tag recorded in the {@link TagLibraryIndex} at compile time.
+ *
+ * @param namespace the tag library namespace the tag is reachable through
+ * @param tagName the tag name within that namespace
+ * @param tagLibraryClassName the binary name of the tag library declaring the 
tag
+ * @param 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,

Review Comment:
   `isMethod()` doesn't exist — the accessor is `isBindable()`. But the point 
underneath was right and worth more than the name: nothing branches on `Kind`, 
and the javadoc claimed it "decides whether a call to it can be resolved", 
which is false since both forms are dispatched by name. Corrected, and `Kind` 
now states why it's recorded with no consumer yet.
   
   `FORMAT_VERSION` is back to 1. On the rest of the dead API: `findTagNames`, 
`getIncompleteNamespaces` and `getAmbiguousTagNames` are gone (the last 
restates `isAmbiguous`, which production uses). I kept `getTagNamesForClass` 
and `isClassDescribed` — spec-only, but each states an invariant nothing else 
does. Say if you want them gone too.



##########
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.
+ *
+ * <p>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 {
+
+    /**

Review Comment:
   The duplication has to stay — the generator forks against the project's 
compile classpath precisely because the plugin doesn't have the framework on 
its own — but the keys are now constants rather than literals, and both sides 
are pinned: `TagLibraryIndexFilesSpec` here and a matching assertion in 
`TagLibraryIndexSpec` there, so renaming either without the other fails a test.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to