jdaugherty commented on code in PR #16385:
URL: https://github.com/apache/grails-core/pull/16385#discussion_r4088090751


##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/scaffolding/GenerateScaffoldedViewsTask.groovy:
##########
@@ -189,54 +217,163 @@ abstract class GenerateScaffoldedViewsTask extends 
DefaultTask {
         templates
     }
 
+    /**
+     * Compiled plugin pages win over runtime scaffolding, so they must also 
win at build time. Each
+     * artifact contributes the first index it carries, as the runtime reads 
only one per plugin.
+     */
+    private Set<String> findPluginViews() {

Review Comment:
   This reads an index out of every artifact on the runtime classpath, where 
the runtime reads one only out of a *binary plugin*: `BinaryGrailsPlugin` calls 
`initializeViewMap` under `if (descriptor != null)`, and that descriptor is 
`META-INF/grails-plugin.xml`. An artifact carrying a view index without one 
contributes nothing at runtime but suppresses generation here, and the page is 
then neither precompiled nor served by a plugin — on the JVM it falls back to 
runtime expansion, in a native image it is gone.
   
   What produces such an artifact is narrow: a Grails application jar taken as 
a dependency, or a plugin repackaged without its descriptor. So leaving it 
alone is a defensible call — a sentence saying so would close it the way the 
`gsp/views.properties` fallback is closed just above. If it is worth matching, 
gating a **jar** entry on `META-INF/grails-plugin.xml` is exact. The directory 
branch cannot be gated the same way and would have to stay as it is: a plugin 
consumed as a project dependency has its descriptor and its index in different 
output directories, and the spec's `plugin-directory` fixture carries no 
descriptor at all.



##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/scaffolding/GenerateScaffoldedViewsTask.groovy:
##########
@@ -189,54 +217,163 @@ abstract class GenerateScaffoldedViewsTask extends 
DefaultTask {
         templates
     }
 
+    /**
+     * Compiled plugin pages win over runtime scaffolding, so they must also 
win at build time. Each
+     * artifact contributes the first index it carries, as the runtime reads 
only one per plugin.
+     */
+    private Set<String> findPluginViews() {
+        Set<String> views = []
+        for (File entry : viewClasspath.files) {
+            Properties index = new Properties()
+            if (entry.isDirectory()) {
+                File resource = VIEW_INDEXES.collect { new File(entry, it) 
}.find { it.isFile() }
+                resource?.withInputStream { InputStream input -> 
index.load(input) }
+            }
+            else if (entry.name.endsWith('.jar') && entry.isFile()) {
+                new JarFile(entry).withCloseable { JarFile jar ->
+                    JarEntry resource = VIEW_INDEXES.collect { 
jar.getJarEntry(it) }.find { it != null }
+                    if (resource != null) {
+                        jar.getInputStream(resource).withCloseable { 
InputStream input -> index.load(input) }
+                    }
+                }
+            }
+            views.addAll(index.stringPropertyNames())
+        }
+        views
+    }
+
     /**
      * Maps view directory name to the fully qualified domain class, for every 
{@code @Scaffold}
      * controller. Qualified rather than simple because a view declaring the 
type of its model has to
      * name a type that resolves.
      *
-     * <p>A view directory is named for the controller alone - {@code 
getDeployedViewURI} builds
-     * {@code /WEB-INF/grails-app/views/<controller>/<view>.gsp} and never 
consults the namespace -
-     * so two controllers of the same simple name in different packages share 
one directory whatever
-     * their namespaces are. Where they scaffold different domains, no single 
page can serve both:
-     * whichever was written would declare one domain as its model and be 
rendered by the controller
-     * of the other. Both are left out rather than one of them guessed at, and 
the resolver goes on
-     * expanding a template per request for them, which is what it did before 
any of this and is the
-     * one thing that gets each controller its own domain. Everything else in 
the project is still
-     * precompiled.</p>
+     * <p>Namespaced controllers are left to the runtime resolver, which can 
evaluate the namespace
+     * and select namespace-specific templates. Emitting their pages into a 
shared, unqualified
+     * directory would make them visible to unrelated controllers. The entire 
shared directory is
+     * left out, including when an unqualified controller also claims it.</p>
+     *
+     * <p>This is deliberately broader than it needs to be for a namespaced 
controller that has no
+     * namespace-specific template, whose page would come out identical to the 
plain one. Narrowing
+     * it needs to know whether {@code <namespace>/<view>.gsp} exists, and 
neither half is available
+     * here: the namespace value is assigned in {@code <clinit>} for the usual 
Groovy declarations,
+     * so the bytecode carries no constant for it, and the runtime also finds 
namespace templates in
+     * places this task does not read - the application's own resources beside 
the controller class,
+     * {@code src/main/templates/scaffolding} in development, and a 
template-override plugin.
+     * Guessing wrong would precompile a plain page over a namespace-specific 
one, silently.</p>
+     *
+     * <p>Likewise, controllers sharing a name but scaffolding different 
domains cannot share a
+     * precompiled page. The runtime resolver expands a template for the 
appropriate domain.</p>
      */
     private Map<String, String> findScaffoldedControllers() {
         Map<String, String> found = [:]
         Map<String, List<String>> claimants = [:]
-        for (File dir : classesDirs.files) {
-            if (!dir.isDirectory()) {
-                continue
-            }
-            dir.eachFileRecurse { File f ->
-                if (!f.name.endsWith('Controller.class')) {
-                    return
+        Set<String> namespaced = []
+        Map<String, Boolean> ancestors = [:]
+        URL[] classpath = (classesDirs.files + 
controllerClasspath.files).collect { it.toURI().toURL() } as URL[]
+        new URLClassLoader(classpath, (ClassLoader) null).withCloseable { 
URLClassLoader resources ->
+            for (File dir : classesDirs.files) {
+                if (!dir.isDirectory()) {
+                    continue
                 }
-                String domain = readScaffoldDomain(f)
-                if (domain == null) {
-                    return
+                dir.eachFileRecurse { File f ->
+                    if (!f.name.endsWith('Controller.class')) {
+                        return
+                    }
+                    String controllerName = viewDirectory(f.name - '.class')
+                    ClassReader reader = new ClassReader(f.bytes)
+                    if (hasNamespace(reader, resources, ancestors)) {
+                        namespaced.add(controllerName)
+                    }
+                    String domain = readScaffoldDomain(reader)
+                    if (domain == null) {
+                        return
+                    }
+                    claimants.computeIfAbsent(controllerName) { [] 
}.add(domain)
+                    found.put(controllerName, domain)
                 }
-                String controllerName = decapitalize(f.name - 
'Controller.class')
-                claimants.computeIfAbsent(controllerName) { [] }.add(domain)
-                found.put(controllerName, domain)
+            }
+        }
+        namespaced.each { String controllerName ->
+            if (found.remove(controllerName) != null) {
+                logger.warn('Not precompiling the views of {}: a controller 
with this name declares or inherits a namespace. ' +
+                        'These scaffold views are expanded at runtime; native 
images require concrete GSP views.', controllerName)
             }
         }
         claimants.each { String controllerName, List<String> domains ->
             List<String> distinct = domains.unique(false)
             if (distinct.size() > 1) {
                 found.remove(controllerName)
                 logger.warn("Not precompiling the views of ${controllerName}: 
" +
-                        "${distinct.size()} controllers named 
${capitalize(controllerName)}Controller " +
+                        "${distinct.size()} controllers with the view 
directory ${controllerName} " +
                         "scaffold different domains (${distinct.join(', ')}) 
and share the one view " +
                         'directory. They are expanded per request instead, as 
they were before.')
             }
         }
         found
     }
 
+    /**
+     * Read declarations, including inherited ones, without evaluating 
application code.
+     *
+     * <p>A declaration is all this can see, not its value, so {@code static 
namespace = null}
+     * still counts even though the runtime, which tests the value, gives that 
controller no
+     * namespace. The value lives in {@code <clinit>} for the usual Groovy 
forms and code is not
+     * read here, so the difference cannot be recovered; the controller is 
only expanded at runtime
+     * rather than precompiled.</p>
+     */
+    private boolean hasNamespace(ClassReader reader, ClassLoader resources, 
Map<String, Boolean> ancestors) {
+        boolean declared = false
+        reader.accept(new ClassVisitor(Opcodes.ASM9) {
+            @Override
+            FieldVisitor visitField(int access, String name, String 
descriptor, String signature, Object value) {
+                if (name == 'namespace' && (access & Opcodes.ACC_STATIC) != 0) 
{
+                    declared = true
+                }
+                null
+            }
+
+            @Override
+            MethodVisitor visitMethod(int access, String name, String 
descriptor, String signature, String[] exceptions) {
+                if (name == 'getNamespace' && descriptor.startsWith('()') && 
(access & Opcodes.ACC_STATIC) != 0) {

Review Comment:
   This branch carries more weight than it looks, and nothing records why. A 
namespace supplied by a trait is the case that needs it. Compiled with Groovy 
5.1.2:
   
   ```groovy
   trait NsTrait { static String namespace = 'admin' }
   class TraitController implements NsTrait { }
   ```
   
   ```
   public class TraitController implements NsTrait, NsTrait$Trait$FieldHelper, 
GroovyObject {
     private static java.lang.String NsTrait__namespace;
     public static java.lang.String getNamespace();
     public static void setNamespace(java.lang.String);
   }
   ```
   
   The field is renamed `NsTrait__namespace`, so `visitField` never sees it; 
only this check does. The same fact is what makes walking `superName` alone 
correct — Groovy puts the static accessor on the implementing class, so there 
is nothing further to find by following interfaces. As it stands, someone who 
notices the walk ignores interfaces has no way to tell that from an oversight, 
and "fixing" it would be wasted work.
   
   The spec's `getter` case is hand-written bytecode that happens to have this 
shape; it does not say where the shape comes from. A sentence in the 
`hasNamespace` javadoc, or a trait case beside the field/getter pair, would pin 
both halves.



##########
grails-doc/src/en/guide/scaffolding.adoc:
##########
@@ -59,6 +59,10 @@ With this configured, when you start your application the 
actions and views will
 
 A CRUD interface will also be generated. To access this open 
`http://localhost:8080/book` in a browser.
 
+During `compileGroovyPages`, the Gradle plugin expands scaffold templates and 
compiles the resulting GSP views so packaged applications and native images do 
not need to generate them on the first request. Handwritten views in the 
application or supplied by a plugin still take precedence over generated 
scaffold views.

Review Comment:
   Two things about this pair of paragraphs.
   
   `compileGroovyPages` compiles the views, but it is `generateScaffoldedViews` 
that expands the templates and `stageGroovyPages` that puts the result beside 
`grails-app/views` for compilation. As written, a reader looking for where the 
expanded pages land, or wanting to run just that step, is pointed at the wrong 
task.
   
   And the namespace rule is documented while its sibling is not. Two 
controllers whose view directories collide and scaffold different domains also 
stay runtime-generated, and for the same reason a native image needs concrete 
views for them — the task warns about that case too, though only the namespace 
warning mentions native images. A reader who hits the collision warning has 
nothing in the guide to look it up in.



##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/scaffolding/GenerateScaffoldedViewsTask.groovy:
##########
@@ -189,54 +217,163 @@ abstract class GenerateScaffoldedViewsTask extends 
DefaultTask {
         templates
     }
 
+    /**
+     * Compiled plugin pages win over runtime scaffolding, so they must also 
win at build time. Each
+     * artifact contributes the first index it carries, as the runtime reads 
only one per plugin.
+     */
+    private Set<String> findPluginViews() {
+        Set<String> views = []
+        for (File entry : viewClasspath.files) {
+            Properties index = new Properties()
+            if (entry.isDirectory()) {
+                File resource = VIEW_INDEXES.collect { new File(entry, it) 
}.find { it.isFile() }
+                resource?.withInputStream { InputStream input -> 
index.load(input) }
+            }
+            else if (entry.name.endsWith('.jar') && entry.isFile()) {
+                new JarFile(entry).withCloseable { JarFile jar ->
+                    JarEntry resource = VIEW_INDEXES.collect { 
jar.getJarEntry(it) }.find { it != null }
+                    if (resource != null) {
+                        jar.getInputStream(resource).withCloseable { 
InputStream input -> index.load(input) }
+                    }
+                }
+            }
+            views.addAll(index.stringPropertyNames())
+        }
+        views
+    }
+
     /**
      * Maps view directory name to the fully qualified domain class, for every 
{@code @Scaffold}
      * controller. Qualified rather than simple because a view declaring the 
type of its model has to
      * name a type that resolves.
      *
-     * <p>A view directory is named for the controller alone - {@code 
getDeployedViewURI} builds
-     * {@code /WEB-INF/grails-app/views/<controller>/<view>.gsp} and never 
consults the namespace -
-     * so two controllers of the same simple name in different packages share 
one directory whatever
-     * their namespaces are. Where they scaffold different domains, no single 
page can serve both:
-     * whichever was written would declare one domain as its model and be 
rendered by the controller
-     * of the other. Both are left out rather than one of them guessed at, and 
the resolver goes on
-     * expanding a template per request for them, which is what it did before 
any of this and is the
-     * one thing that gets each controller its own domain. Everything else in 
the project is still
-     * precompiled.</p>
+     * <p>Namespaced controllers are left to the runtime resolver, which can 
evaluate the namespace
+     * and select namespace-specific templates. Emitting their pages into a 
shared, unqualified
+     * directory would make them visible to unrelated controllers. The entire 
shared directory is
+     * left out, including when an unqualified controller also claims it.</p>
+     *
+     * <p>This is deliberately broader than it needs to be for a namespaced 
controller that has no
+     * namespace-specific template, whose page would come out identical to the 
plain one. Narrowing
+     * it needs to know whether {@code <namespace>/<view>.gsp} exists, and 
neither half is available
+     * here: the namespace value is assigned in {@code <clinit>} for the usual 
Groovy declarations,
+     * so the bytecode carries no constant for it, and the runtime also finds 
namespace templates in
+     * places this task does not read - the application's own resources beside 
the controller class,
+     * {@code src/main/templates/scaffolding} in development, and a 
template-override plugin.
+     * Guessing wrong would precompile a plain page over a namespace-specific 
one, silently.</p>
+     *
+     * <p>Likewise, controllers sharing a name but scaffolding different 
domains cannot share a
+     * precompiled page. The runtime resolver expands a template for the 
appropriate domain.</p>
      */
     private Map<String, String> findScaffoldedControllers() {
         Map<String, String> found = [:]
         Map<String, List<String>> claimants = [:]
-        for (File dir : classesDirs.files) {
-            if (!dir.isDirectory()) {
-                continue
-            }
-            dir.eachFileRecurse { File f ->
-                if (!f.name.endsWith('Controller.class')) {
-                    return
+        Set<String> namespaced = []
+        Map<String, Boolean> ancestors = [:]
+        URL[] classpath = (classesDirs.files + 
controllerClasspath.files).collect { it.toURI().toURL() } as URL[]
+        new URLClassLoader(classpath, (ClassLoader) null).withCloseable { 
URLClassLoader resources ->
+            for (File dir : classesDirs.files) {
+                if (!dir.isDirectory()) {
+                    continue
                 }
-                String domain = readScaffoldDomain(f)
-                if (domain == null) {
-                    return
+                dir.eachFileRecurse { File f ->
+                    if (!f.name.endsWith('Controller.class')) {
+                        return
+                    }
+                    String controllerName = viewDirectory(f.name - '.class')
+                    ClassReader reader = new ClassReader(f.bytes)
+                    if (hasNamespace(reader, resources, ancestors)) {
+                        namespaced.add(controllerName)
+                    }
+                    String domain = readScaffoldDomain(reader)
+                    if (domain == null) {
+                        return
+                    }
+                    claimants.computeIfAbsent(controllerName) { [] 
}.add(domain)
+                    found.put(controllerName, domain)
                 }
-                String controllerName = decapitalize(f.name - 
'Controller.class')
-                claimants.computeIfAbsent(controllerName) { [] }.add(domain)
-                found.put(controllerName, domain)
+            }
+        }
+        namespaced.each { String controllerName ->
+            if (found.remove(controllerName) != null) {
+                logger.warn('Not precompiling the views of {}: a controller 
with this name declares or inherits a namespace. ' +
+                        'These scaffold views are expanded at runtime; native 
images require concrete GSP views.', controllerName)
             }
         }
         claimants.each { String controllerName, List<String> domains ->
             List<String> distinct = domains.unique(false)
             if (distinct.size() > 1) {
                 found.remove(controllerName)
                 logger.warn("Not precompiling the views of ${controllerName}: 
" +
-                        "${distinct.size()} controllers named 
${capitalize(controllerName)}Controller " +
+                        "${distinct.size()} controllers with the view 
directory ${controllerName} " +
                         "scaffold different domains (${distinct.join(', ')}) 
and share the one view " +
                         'directory. They are expanded per request instead, as 
they were before.')
             }
         }
         found
     }
 
+    /**
+     * Read declarations, including inherited ones, without evaluating 
application code.
+     *
+     * <p>A declaration is all this can see, not its value, so {@code static 
namespace = null}
+     * still counts even though the runtime, which tests the value, gives that 
controller no
+     * namespace. The value lives in {@code <clinit>} for the usual Groovy 
forms and code is not
+     * read here, so the difference cannot be recovered; the controller is 
only expanded at runtime
+     * rather than precompiled.</p>
+     */
+    private boolean hasNamespace(ClassReader reader, ClassLoader resources, 
Map<String, Boolean> ancestors) {
+        boolean declared = false
+        reader.accept(new ClassVisitor(Opcodes.ASM9) {
+            @Override
+            FieldVisitor visitField(int access, String name, String 
descriptor, String signature, Object value) {
+                if (name == 'namespace' && (access & Opcodes.ACC_STATIC) != 0) 
{
+                    declared = true
+                }
+                null
+            }
+
+            @Override
+            MethodVisitor visitMethod(int access, String name, String 
descriptor, String signature, String[] exceptions) {
+                if (name == 'getNamespace' && descriptor.startsWith('()') && 
(access & Opcodes.ACC_STATIC) != 0) {
+                    declared = true
+                }
+                null
+            }
+        }, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | 
ClassReader.SKIP_FRAMES)
+        if (declared || reader.superName == null || reader.superName == 
'java/lang/Object') {
+            return declared
+        }
+        String superName = reader.superName
+        Boolean known = ancestors.get(superName)
+        if (known != null) {
+            return known
+        }
+        boolean inherited = ancestorHasNamespace(superName, resources, 
ancestors)
+        ancestors.put(superName, inherited)
+        inherited
+    }
+
+    /**
+     * Superclasses can come from dependencies, whose class files may be newer 
than the bundled ASM
+     * reads. One that cannot be read is taken to declare no namespace rather 
than failing the build.
+     */
+    private boolean ancestorHasNamespace(String internalName, ClassLoader 
resources, Map<String, Boolean> ancestors) {
+        InputStream parent = 
resources.getResourceAsStream("${internalName}.class")
+        if (parent == null) {
+            return false
+        }
+        ClassReader reader
+        try {
+            reader = parent.withCloseable { InputStream input -> new 
ClassReader(input) }
+        }
+        catch (IllegalArgumentException e) {

Review Comment:
   The catch is narrower than the failure it is here to absorb. Running the 
bundled `groovyjarjarasm` reader over a few shapes of input:
   
   ```
   intact                read ok
   future major version  IllegalArgumentException: Unsupported class file major 
version 200
   not a class file      IllegalArgumentException: Unsupported class file major 
version 30575
   truncated             ArrayIndexOutOfBoundsException: Index 774 out of 
bounds for length 758
   empty                 ArrayIndexOutOfBoundsException: Index 6 out of bounds 
for length 0
   ```
   
   Only the version check raises `IllegalArgumentException`. The reader is 
already into the constant pool before it can tell a truncated class file from a 
whole one, so that case arrives as an index error instead. `new 
ClassReader(InputStream)` also declares `IOException`, which a jar with a 
damaged entry raises right here.
   
   Both land where the javadoc above says the build carries on, and both fail 
it with a stack trace naming neither the controller nor the jar — the outcome 
the catch exists to prevent. Catching `IllegalArgumentException | IOException | 
IndexOutOfBoundsException`, or just `Exception`, would make the behaviour match 
what is written above it.



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