Add @GrailsCompileStatic support for controllers that call tag libs#15669
Add @GrailsCompileStatic support for controllers that call tag libs#15669jdaugherty wants to merge 1 commit into
@GrailsCompileStatic support for controllers that call tag libs#15669Conversation
14b0664 to
436b082
Compare
436b082 to
e5d44d2
Compare
✅ All tests passed ✅Test Summary
🏷️ Commit: e5d44d2 Learn more about TestLens at testlens.app. |
jamesfredley
left a comment
There was a problem hiding this comment.
Nice work on this - it tackles a real long-standing pain point and the architectural choice (a TypeCheckingExtension hooking the methodMissing / propertyMissing weave from TagLibraryInvoker) looks right. CI is green across all 31 jobs.
Left some inline notes below - mostly minor style/consistency things, plus a couple of behavior cases that might be worth a second look (the broadness of unresolvedVariable and the tag-as-property closure form). None are dealbreakers, just thoughts.
| unresolvedVariable { VariableExpression ve -> | ||
| if (currentScope?.isController) { | ||
| currentScope.dynamicNamespaceProperties << ve | ||
| return makeDynamic(ve) | ||
| } | ||
| null | ||
| } |
There was a problem hiding this comment.
This part might be a bit broader than necessary - marking every unresolved variable in a controller as dynamic could end up silencing genuine typos. For example:
@GrailsCompileStatic
class BookController {
BookService bookSvc
def index() {
bookSvce.list() // typo - 'bookSvce' is unresolved
}
}Here bookSvce would be silently made dynamic (and added to dynamicNamespaceProperties, so the subsequent .list() call is also silenced) instead of producing the compile error @GrailsCompileStatic users probably expect.
Could it maybe be worth narrowing this? One option would be to defer the dynamic mark until the variable is actually used as the receiver of a method call (i.e. only silence the namespace-dispatcher access pattern <ident>.<method>(...)), so standalone typos still surface. Just a thought - happy to be wrong if there's a reason you've gone broader.
| unresolvedProperty { PropertyExpression pe -> | ||
| if (currentScope?.isController && isThisReceiver(pe)) { | ||
| currentScope.dynamicNamespaceProperties << pe | ||
| return makeDynamic(pe) | ||
| } | ||
| null | ||
| } |
There was a problem hiding this comment.
Just flagging a subtle case here: this hook silences def t = link at compile time, but at runtime TagLibraryInvoker.propertyMissing only returns a NamespacedTagDispatcher for namespace names - it doesn't return a Closure for tag-property access the way TagLibrary.propertyMissing does inside a tag library itself. So a controller with def t = link would compile cleanly but throw MissingPropertyException at runtime.
Probably fine as a known limitation, but maybe worth a Javadoc note here (or a line in the user docs) so the boundary is explicit?
| * {@code g.message(code: 'key')}, {@code my.customTag(attr: 'val')}</li> | ||
| * </ul> | ||
| * | ||
| * @since 7.0 |
There was a problem hiding this comment.
Tiny thing - since this PR targets 8.0.0-SNAPSHOT and whatsNew.adoc describes the change under "introduced in Grails 8", @since 8.0 would maybe be more accurate here?
| beforeVisitClass { ClassNode classNode -> | ||
| newScope { | ||
| isController = classNode.name.endsWith(ControllerArtefactHandler.TYPE) | ||
| dynamicNamespaceProperties = [] as Set | ||
| } | ||
| } | ||
|
|
||
| afterVisitClass { ClassNode classNode -> | ||
| scopeExit() | ||
| } |
There was a problem hiding this comment.
Minor consistency note: the other class-scoped extensions in this package (ValidateableTypeCheckingExtension, DomainMappingTypeCheckingExtension, NamedQueryTypeCheckingExtension) pair beforeVisitClass / afterVisitClass with an outer setup { newScope() } / finish { scopeExit() } guard:
setup { newScope() }
finish { scopeExit() }The currentScope? null-safe accesses below make this functionally safe without them, but could you maybe add the outer pair to keep this in lockstep with the rest of the package?
| null | ||
| } | ||
|
|
||
| methodNotFound { receiver, name, argList, argTypes, call -> |
There was a problem hiding this comment.
Could you maybe add parameter types here for consistency with the other extensions in this package? They all use the fully typed form:
methodNotFound { ClassNode receiver, String name, ArgumentListExpression argList, ClassNode[] argTypes, MethodCall call ->| if (call instanceof MethodCallExpression && call.objectExpression in currentScope.dynamicNamespaceProperties) return makeDynamic(call) | ||
| null | ||
| } | ||
| } |
There was a problem hiding this comment.
The other extensions in this package all end run() with an explicit null. Could you maybe add one here for consistency? Just a style nit.
| } | ||
| } | ||
|
|
||
| private boolean isThisReceiver(expr) { |
There was a problem hiding this comment.
Could you maybe type this parameter (perhaps Expression expr)? Helps line up with the rest of the codebase's static-typing lean.
| Object run() { | ||
| beforeVisitClass { ClassNode classNode -> | ||
| newScope { | ||
| isController = classNode.name.endsWith(ControllerArtefactHandler.TYPE) |
There was a problem hiding this comment.
Since detection is purely by name suffix, an inner class called something like FooController declared inside, say, a service would also receive the silencing treatment. Probably rare in practice - but might be worth a Javadoc note so the behavior is documented?
| @Tag('http-client') | ||
| class CompileStaticControllerSpec extends Specification implements HttpClientSupport { | ||
|
|
||
| void 'controller with @GrailsCompileStatic can call a default-namespace tag directly'() { |
There was a problem hiding this comment.
Tiny convenience nit: this integration spec and the unit spec at src/test/groovy/demo/CompileStaticControllerSpec.groovy share the same fully qualified name demo.CompileStaticControllerSpec. Different source sets so the build is fine, but --tests "demo.CompileStaticControllerSpec" becomes ambiguous when running them individually.
Could you maybe rename one to something like CompileStaticControllerIntegrationSpec (or CompileStaticControllerHttpSpec)?
| import spock.lang.Specification | ||
|
|
||
| class ControllerCompileStaticTagLibSpec extends Specification implements ControllerUnitTest<CompileStaticTagController> { | ||
|
|
There was a problem hiding this comment.
Could it maybe be worth adding a negative test alongside these positive ones? Something pinning down the boundary - e.g. a spec asserting that a genuinely undefined reference inside an @GrailsCompileStatic controller still fails to compile - would help guard against the extension drifting to silence more than intended in future changes.
Not a blocker, just a thought.
Fixes #14023