jdaugherty commented on code in PR #16042:
URL: https://github.com/apache/grails-core/pull/16042#discussion_r3652174381
##########
grails-core/src/main/groovy/org/grails/compiler/injection/GlobalGrailsClassInjectorTransformation.groovy:
##########
@@ -347,17 +303,104 @@ class GlobalGrailsClassInjectorTransformation implements
ASTTransformation, Comp
@CompileDynamic
protected static void handleExcludes(GPathResult pluginXml) {
if (pluginExcludes) {
-
def antPathMatcher = new AntPathMatcher()
- pluginXml.resources.resource.each { res ->
- if (pluginExcludes.any() { String exc ->
antPathMatcher.match(exc, res.text().replace('.', '/')) }) {
- res.replaceNode {}
+ pluginXml.resources.resource.each {
+ def resourceNode = it as GPathResult
+ if (pluginExcludes.any() { antPathMatcher.match(it,
resourceNode.text().replace('.', '/')) }) {
+ resourceNode.replaceNode {}
}
}
}
}
- public static final ClassNode ARTEFACT_CLASS_NODE = new ClassNode(Artefact)
+ @CompileDynamic
+ private static Writable createMarkup(GPathResult node) {
+ new StreamingMarkupBuilder().mkp.yield(node)
Review Comment:
This throws every time it is called. `mkp` is not a property of
`StreamingMarkupBuilder` — it is a pseudo-namespace that only resolves against
the builder's delegate *inside* a `bind {}` closure, so accessing it on the
instance fails:
```
groovy.lang.MissingPropertyException: No such property: mkp for class:
groovy.xml.StreamingMarkupBuilder
Possible solutions: qt
```
(verified against groovy-xml 5.0.7; `bind { mkp.yield(node) }` on the same
node returns the expected `<plugin name='foo'>...</plugin>`.)
Because `updatePluginXml` wraps everything in `catch (ignored)`, the failure
is swallowed and the method falls through to `writePluginXml`. Two consequences:
- For the `pluginClassNode == null` branch — `generatePluginXml` →
`updatePluginXml(null, ...)`, which is the path every non-descriptor source
unit takes — the fallback `writePluginXml(null, ...)` is a no-op because of its
`if (pluginClassNode)` guard. Artefact resources discovered in source units
compiled after the `*GrailsPlugin` class are therefore silently dropped from
`META-INF/grails-plugin.xml`, and `pendingPluginClasses` is never cleared.
- For the `pluginClassNode != null` branch the descriptor is fully
regenerated from the AST on every pass rather than merged, so anything already
in the file that is not reproducible from the AST is lost.
Keeping the `bind {}` form preserves the original behaviour and still gets
you the extracted helper:
```groovy
@CompileDynamic
private static Writable createMarkup(GPathResult node) {
new StreamingMarkupBuilder().bind { mkp.yield(node) }
}
```
(`bindNode(node)` — defined as `bind { out << node }` — may also fit, but it
is a different rendering call than `mkp.yield`, so it would need verifying
against the descriptor output.)
Worth a test that fails on the current code: put an element into an existing
`plugin.xml` that the AST cannot reproduce (or call `updatePluginXml(null,
'1.0', file, ['Foo'])`) and assert it survives the update.
##########
grails-core/src/main/groovy/org/grails/compiler/injection/GlobalGrailsClassInjectorTransformation.groovy:
##########
@@ -347,17 +303,104 @@ class GlobalGrailsClassInjectorTransformation implements
ASTTransformation, Comp
@CompileDynamic
protected static void handleExcludes(GPathResult pluginXml) {
if (pluginExcludes) {
-
def antPathMatcher = new AntPathMatcher()
- pluginXml.resources.resource.each { res ->
- if (pluginExcludes.any() { String exc ->
antPathMatcher.match(exc, res.text().replace('.', '/')) }) {
- res.replaceNode {}
+ pluginXml.resources.resource.each {
+ def resourceNode = it as GPathResult
+ if (pluginExcludes.any() { antPathMatcher.match(it,
resourceNode.text().replace('.', '/')) }) {
+ resourceNode.replaceNode {}
}
}
}
}
- public static final ClassNode ARTEFACT_CLASS_NODE = new ClassNode(Artefact)
+ @CompileDynamic
+ private static Writable createMarkup(GPathResult node) {
+ new StreamingMarkupBuilder().mkp.yield(node)
+ }
- CompilationUnit compilationUnit
+ private static Object resolveProjectVersion(ClassNode classNode) {
+ def projectVersion = classNode.getNodeMetaData('projectVersion')
+ if (projectVersion == null) {
+ projectVersion = getClass().package.implementationVersion
Review Comment:
Moving this expression out of the instance method `visit()` and into a
`static` helper changes what `getClass()` resolves to. In a static Groovy
method the implicit receiver is the class object, so `getClass()` returns
`java.lang.Class` — not `GlobalGrailsClassInjectorTransformation`:
```groovy
@CompileStatic
class Probe {
static Object fromStatic() { getClass() } // -> class java.lang.Class
Object fromInstance() { getClass() } // -> class Probe
}
```
`Class.class.getPackage().getImplementationVersion()` is `null` (java.base
carries no manifest attributes), whereas the previous instance-context lookup
read `Implementation-Version` off the grails-core jar — which
`CompilePlugin.groovy` does set to `grailsVersion`. So this fallback now always
yields `null` instead of the Grails version.
That propagates: `pluginVersion` becomes `null`, and
`addPluginVersionProperty` calls `pluginVersion.toString()` with no null guard
(line 349), so a `*GrailsPlugin` class compiled without `projectVersion` node
metadata — plain `groovyc`, Groovy-Eclipse, anything that is not the Grails
Gradle plugin stamping the customizer — now NPEs during CANONICALIZATION rather
than getting the framework version.
Either keep the lookup anchored to the class explicitly:
```groovy
projectVersion =
GlobalGrailsClassInjectorTransformation.package.implementationVersion
```
or pass the resolved version in. Same latent issue exists in
`writePluginXml`/`writePluginXmlProperties`, but those were already static
before this PR, so it is pre-existing there rather than introduced.
##########
grails-core/src/test/groovy/org/grails/compiler/injection/GlobalGrailsClassInjectorTransformationSpec.groovy:
##########
@@ -221,4 +228,160 @@ class FooGrailsPlugin {
cleanup:
projectDir.deleteDir()
}
+
+ void "priority returns the global grails transform order"() {
+ expect:
+ new GlobalGrailsClassInjectorTransformation().priority() ==
GroovyTransformOrder.GLOBAL_GRAILS_TRANSFORM_ORDER
+ }
+
+ /**
+ * Compiles the given source to a real file on disk (required so {@code
GrailsASTUtils.getSourceUrl}
+ * resolves a URL). Because {@code
GlobalGrailsClassInjectorTransformation} is itself registered as a
+ * global AST transformation (via {@code META-INF/services}) and
grails-core's own compiled classes are
+ * on this test's classpath, compiling all the way through {@code
CANONICALIZATION} exercises the real
+ * transformation exactly as production Grails builds do - no manual
{@code visit()} call is needed.
+ * {@code nodeMetaData} is stamped onto the class during {@code
CONVERSION}, before the transformation's
+ * own {@code CANONICALIZATION} pass runs, mirroring how the Grails Gradle
plugin stamps project
+ * name/version metadata via its own compiler customizer.
+ */
+ private static List compileToFile(File sourceFile, String source, File
targetDirectory, Map<String, String> nodeMetaData = [:]) {
+ sourceFile.parentFile.mkdirs()
+ sourceFile.text = source
+ def configuration = new CompilerConfiguration()
+ configuration.setTargetDirectory(targetDirectory)
+ if (nodeMetaData) {
+ configuration.addCompilationCustomizers(new
CompilationCustomizer(CompilePhase.CONVERSION) {
+ @Override
+ void call(SourceUnit source1, GeneratorContext context,
ClassNode cn) throws CompilationFailedException {
+ nodeMetaData.each { key, value -> cn.putNodeMetaData(key,
value) }
+ }
+ })
+ }
+ CompilationUnit cu = new CompilationUnit(configuration)
+ cu.addSource(sourceFile)
+ ClassNode capturedClassNode = null
+ SourceUnit capturedSourceUnit = null
+ cu.addPhaseOperation(new CompilationUnit.PrimaryClassNodeOperation() {
+ @Override
+ void call(SourceUnit source1, GeneratorContext context, ClassNode
cn) throws CompilationFailedException {
+ capturedClassNode = cn
+ capturedSourceUnit = source1
+ }
+ }, Phases.CANONICALIZATION)
+ cu.compile(Phases.CANONICALIZATION)
+ [capturedClassNode, capturedSourceUnit]
Review Comment:
`capturedSourceUnit` is returned but never used — all three callers
destructure it into a `sourceUnit` variable that is then dead. Dropping it lets
`compileToFile` return the `ClassNode` directly and removes the awkward
`source1` parameter naming in both inner classes.
Also worth adding the `pendingPluginClasses` / `pluginExcludes` reset to
these three specs' `cleanup:` blocks, as the excludes test below already does.
Compiling for real drives `generatePluginXml`, which mutates both static
collections, and with `maxParallelForks > 1` that state outlives the feature
method inside the fork.
##########
grails-core/src/test/groovy/org/grails/compiler/injection/GlobalGrailsClassInjectorTransformationSpec.groovy:
##########
@@ -20,16 +20,23 @@ package org.grails.compiler.injection
import groovy.xml.MarkupBuilder
import groovy.xml.XmlSlurper
+import org.codehaus.groovy.ast.ClassHelper
import org.codehaus.groovy.ast.ClassNode
import org.codehaus.groovy.classgen.GeneratorContext
import org.codehaus.groovy.control.CompilationFailedException
import org.codehaus.groovy.control.CompilationUnit
+import org.codehaus.groovy.control.CompilePhase
import org.codehaus.groovy.control.CompilerConfiguration
import org.codehaus.groovy.control.Phases
import org.codehaus.groovy.control.SourceUnit
+import org.codehaus.groovy.control.customizers.CompilationCustomizer
import spock.lang.Specification
import spock.util.environment.RestoreSystemProperties
+import grails.plugins.metadata.GrailsPlugin
+import grails.util.GrailsNameUtils
+import org.apache.grails.common.compiler.GroovyTransformOrder
+
/**
* Created by graemerocher on 19/09/14.
Review Comment:
Might as well remove this since we're removing authors too.
##########
grails-core/src/main/groovy/org/grails/compiler/injection/GlobalGrailsClassInjectorTransformation.groovy:
##########
@@ -285,60 +273,28 @@ class GlobalGrailsClassInjectorTransformation implements
ASTTransformation, Comp
}
}
- @CompileDynamic
- static void updatePluginXml(ClassNode pluginClassNode, String
pluginVersion, File pluginXmlFile, Collection<String> artefactClasses) {
+ static void updatePluginXml(ClassNode pluginClassNode, Object
pluginVersion, File pluginXmlFile, Collection<String> artefactClasses) {
if (!artefactClasses) return
-
try {
- XmlSlurper xmlSlurper = IOUtils.createXmlSlurper()
-
- def pluginXml = xmlSlurper.parse(pluginXmlFile)
+ def pluginXml = IOUtils.createXmlSlurper().parse(pluginXmlFile)
if (pluginClassNode) {
- def pluginName =
GrailsNameUtils.getLogicalPropertyName(pluginClassNode.name, 'GrailsPlugin')
- pluginXml.@name = pluginName
- pluginXml.@version = pluginVersion
- pluginXml.type = pluginClassNode.name
-
- PluginAstReader pluginAstReader = new PluginAstReader()
- def info = pluginAstReader.readPluginInfo(pluginClassNode)
-
- def pluginProperties = info.getProperties()
- def grailsVersion = pluginProperties['grailsVersion'] ?:
getClass().getPackage().getImplementationVersion() + ' > *'
- pluginXml.@grailsVersion = grailsVersion
- for (entry in pluginProperties) {
- pluginXml."$entry.key" = entry.value
- }
-
+ def pluginProperties =
writePluginXmlProperties(pluginClassNode, pluginVersion.toString(), pluginXml)
def excludes = pluginProperties.get('pluginExcludes')
if (excludes instanceof List) {
pluginExcludes.clear()
- pluginExcludes.addAll(excludes)
- }
- }
-
- def resources = pluginXml.resources
-
- for (String cn in artefactClasses) {
- if (!resources.resource.find { it.text() == cn }) {
- resources.appendNode {
- resource(cn)
- }
+ pluginExcludes.addAll(excludes as List<String>)
}
}
-
+ writePluginXmlResources(pluginXml, artefactClasses)
handleExcludes(pluginXml)
- Writable writable = new StreamingMarkupBuilder().bind {
- mkp.yield(pluginXml)
- }
-
- pluginXmlFile.withWriter(StandardCharsets.UTF_8.name()) { Writer
writer ->
- writable.writeTo(writer)
+ pluginXmlFile.withWriter(StandardCharsets.UTF_8.name()) {
+ createMarkup(pluginXml).writeTo(it)
}
pendingPluginClasses.clear()
- } catch (e) {
+ } catch (ignored) {
Review Comment:
This catch-all is what turns the `createMarkup` defect above into a silent,
test-invisible fallback: it catches every `Exception`, not just the "XML is
corrupt" case the comment describes, and discards it without a trace.
Given the transform runs inside the compiler, a log line (or narrowing to
the parse/IO failures that actually mean "corrupt, recreate") would have
surfaced the `MissingPropertyException` immediately. Naming the parameter
`ignored` also asserts the exception is uninteresting, which is the opposite of
true here.
##########
grails-core/src/main/groovy/org/grails/compiler/injection/GlobalGrailsClassInjectorTransformation.groovy:
##########
@@ -212,14 +196,18 @@ class GlobalGrailsClassInjectorTransformation implements
ASTTransformation, Comp
}
static boolean updateGrailsFactoriesWithType(ClassNode classNode,
ClassNode superType, File compilationTargetDirectory) {
- FactoriesFileWriter.updateFactoriesWithType(classNode, superType,
compilationTargetDirectory,
- 'META-INF/grails.factories',
['src/main/resources/META-INF/grails.factories'])
+ FactoriesFileWriter.updateFactoriesWithType(
+ classNode,
+ superType,
+ compilationTargetDirectory,
+ 'META-INF/grails.factories',
+ ['src/main/resources/META-INF/grails.factories']
+ )
}
- static LinkedHashSet<String> pendingPluginClasses = []
- static Collection<String> pluginExcludes = []
-
- protected static void generatePluginXml(ClassNode pluginClassNode, String
pluginVersion, Set<String> transformedClasses, File pluginXmlFile) {
+ protected static void generatePluginXml(ClassNode pluginClassNode, Object
pluginVersion, Set<String> transformedClasses, File pluginXmlFile) {
Review Comment:
`pluginVersion` widened from `String` to `Object` here and on the two
`public static` methods below. Nothing in this repo calls them (only the spec
does), but they are public/protected static entry points on 8.0.x, so this is a
binary-incompatible signature change for any external plugin build compiled
against them, for no gain — the type is immediately narrowed again by
`.toString()` at five separate call sites.
The cleaner shape is to normalise once where the value originates, in
`visit()`, and leave these signatures as `String`:
```groovy
String pluginVersion = null
...
pluginVersion = projectVersion?.toString()
```
That also gives you the natural place to decide what a missing version
means, instead of deferring it to an unguarded `pluginVersion.toString()` in
`addPluginVersionProperty` (see the `resolveProjectVersion` comment).
##########
grails-core/src/test/groovy/org/grails/compiler/injection/GlobalGrailsClassInjectorTransformationSpec.groovy:
##########
@@ -221,4 +228,160 @@ class FooGrailsPlugin {
cleanup:
projectDir.deleteDir()
}
+
+ void "priority returns the global grails transform order"() {
+ expect:
+ new GlobalGrailsClassInjectorTransformation().priority() ==
GroovyTransformOrder.GLOBAL_GRAILS_TRANSFORM_ORDER
+ }
+
+ /**
+ * Compiles the given source to a real file on disk (required so {@code
GrailsASTUtils.getSourceUrl}
+ * resolves a URL). Because {@code
GlobalGrailsClassInjectorTransformation} is itself registered as a
+ * global AST transformation (via {@code META-INF/services}) and
grails-core's own compiled classes are
+ * on this test's classpath, compiling all the way through {@code
CANONICALIZATION} exercises the real
+ * transformation exactly as production Grails builds do - no manual
{@code visit()} call is needed.
+ * {@code nodeMetaData} is stamped onto the class during {@code
CONVERSION}, before the transformation's
+ * own {@code CANONICALIZATION} pass runs, mirroring how the Grails Gradle
plugin stamps project
+ * name/version metadata via its own compiler customizer.
+ */
+ private static List compileToFile(File sourceFile, String source, File
targetDirectory, Map<String, String> nodeMetaData = [:]) {
+ sourceFile.parentFile.mkdirs()
+ sourceFile.text = source
+ def configuration = new CompilerConfiguration()
+ configuration.setTargetDirectory(targetDirectory)
+ if (nodeMetaData) {
+ configuration.addCompilationCustomizers(new
CompilationCustomizer(CompilePhase.CONVERSION) {
+ @Override
+ void call(SourceUnit source1, GeneratorContext context,
ClassNode cn) throws CompilationFailedException {
+ nodeMetaData.each { key, value -> cn.putNodeMetaData(key,
value) }
+ }
+ })
+ }
+ CompilationUnit cu = new CompilationUnit(configuration)
+ cu.addSource(sourceFile)
+ ClassNode capturedClassNode = null
+ SourceUnit capturedSourceUnit = null
+ cu.addPhaseOperation(new CompilationUnit.PrimaryClassNodeOperation() {
+ @Override
+ void call(SourceUnit source1, GeneratorContext context, ClassNode
cn) throws CompilationFailedException {
+ capturedClassNode = cn
+ capturedSourceUnit = source1
+ }
+ }, Phases.CANONICALIZATION)
+ cu.compile(Phases.CANONICALIZATION)
+ [capturedClassNode, capturedSourceUnit]
+ }
+
+ void "the global transform stamps a GrailsPlugin descriptor class with a
version property"() {
+ given: "a *GrailsPlugin class with no explicit version property, and
nowhere for plugin.xml to exist yet"
+ File projectDir = File.createTempDir()
+ File sourceFile = new File(projectDir, 'PlainGrailsPlugin.groovy')
+ File targetDirectory = new File(projectDir,
'build/classes/groovy/main')
+
+ when: "the source is compiled, exercising the registered global
transform"
+ def (ClassNode classNode, SourceUnit sourceUnit) =
compileToFile(sourceFile, '''
+class PlainGrailsPlugin {
+}
+''', targetDirectory, [projectVersion: '1.5'])
+
+ then: "the plugin class is stamped with the resolved version"
+ classNode.getProperty('version') != null
+
+ and: "the plugin.xml describing it is generated as a side effect"
+ new File(targetDirectory, 'META-INF/grails-plugin.xml').exists()
+
+ cleanup:
+ projectDir.deleteDir()
+ }
+
+ void "the global transform annotates a plain Grails resource class with
@GrailsPlugin metadata"() {
+ given: "a class under grails-app that isn't matched by any registered
ArtefactHandler"
+ File projectDir = File.createTempDir()
+ File sourceFile = new File(projectDir,
'grails-app/services/FooWidget.groovy')
+ File targetDirectory = new File(projectDir,
'build/classes/groovy/main')
+
+ when: "the source is compiled, exercising the registered global
transform"
+ def (ClassNode classNode, SourceUnit sourceUnit) =
compileToFile(sourceFile, '''
+class FooWidget {
+}
+''', targetDirectory, [projectName: 'foowidget', projectVersion: '2.0'])
+
+ then: "the class is stamped with the project's @GrailsPlugin metadata"
+ def annotations =
classNode.getAnnotations(ClassHelper.make(GrailsPlugin))
+ annotations.size() == 1
+ annotations[0].getMember('name').text ==
GrailsNameUtils.getPropertyNameForLowerCaseHyphenSeparatedName('foowidget')
+ annotations[0].getMember('version').text == '2.0'
+
+ cleanup:
+ projectDir.deleteDir()
+ }
+
+ void "the global transform skips classes whose source falls outside the
Grails resource patterns"() {
+ given: "a plain source under src/main/groovy, which is project source
but not a Grails resource"
+ File projectDir = File.createTempDir()
+ File sourceFile = new File(projectDir,
'src/main/groovy/PlainClass.groovy')
+ File targetDirectory = new File(projectDir,
'build/classes/groovy/main')
+
+ when: "the source is compiled, exercising the registered global
transform"
+ def (ClassNode classNode, SourceUnit sourceUnit) =
compileToFile(sourceFile, '''
+class PlainClass {
+}
+''', targetDirectory, [projectName: 'plain', projectVersion: '1.0'])
+
+ then: "no @GrailsPlugin metadata is stamped on the class"
+ classNode.getAnnotations(ClassHelper.make(GrailsPlugin)).isEmpty()
+
+ cleanup:
+ projectDir.deleteDir()
+ }
+
+ void "Test that plugin dot xml excludes are honoured and metadata
refreshed when updating an existing file"() {
Review Comment:
This test does not exercise the path its name claims. Because `createMarkup`
throws (see the main-file comment), `updatePluginXml` aborts before writing and
`catch (ignored)` re-runs `writePluginXml`, which regenerates the descriptor
from the AST — and since `writePluginXml` applies `pluginExcludes` to
`artefactClasses` itself and reads `grailsVersion` from the same plugin
properties, it produces exactly the asserted `version == '2.0'`, `grailsVersion
== '3.0 > *'`, `resource*.text() == ['KeptThing']`. The assertions pass whether
the update path works or is entirely broken.
The pre-existing "plugin dot xml file is updated when the plugin dot xml
does exist" test has the same blind spot. To pin the update semantics, assert
on something only the merge path can produce — e.g. seed the existing
`plugin.xml` with a resource that is *not* in `artefactClasses` and require it
to survive, or drive `updatePluginXml(null, ...)` where the rewrite fallback is
a no-op.
##########
grails-core/src/main/groovy/org/grails/compiler/injection/GlobalGrailsClassInjectorTransformation.groovy:
##########
@@ -347,17 +303,104 @@ class GlobalGrailsClassInjectorTransformation implements
ASTTransformation, Comp
@CompileDynamic
protected static void handleExcludes(GPathResult pluginXml) {
if (pluginExcludes) {
-
def antPathMatcher = new AntPathMatcher()
- pluginXml.resources.resource.each { res ->
- if (pluginExcludes.any() { String exc ->
antPathMatcher.match(exc, res.text().replace('.', '/')) }) {
- res.replaceNode {}
+ pluginXml.resources.resource.each {
+ def resourceNode = it as GPathResult
+ if (pluginExcludes.any() { antPathMatcher.match(it,
resourceNode.text().replace('.', '/')) }) {
Review Comment:
The inner closure's implicit `it` shadows the outer `each`'s `it`, so this
line reads as if the resource were being matched against itself. It is in fact
correct — I confirmed the inner `it` binds to the exclude pattern and
`resourceNode` still captures the resource — but the previous `{ String exc ->
antPathMatcher.match(exc, res.text()...) }` said so plainly, and this is a
readability step backwards in a refactoring PR. Keeping the explicit parameter
costs nothing:
```groovy
pluginXml.resources.resource.each { res ->
def resourceNode = res as GPathResult
if (pluginExcludes.any { String exc -> antPathMatcher.match(exc,
resourceNode.text().replace('.', '/')) }) {
resourceNode.replaceNode {}
}
}
```
--
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]