Skip to content

Add @GrailsCompileStatic support for controllers that call tag libs#15669

Open
jdaugherty wants to merge 2 commits into
8.0.xfrom
feature/compileStaticTagLibs
Open

Add @GrailsCompileStatic support for controllers that call tag libs#15669
jdaugherty wants to merge 2 commits into
8.0.xfrom
feature/compileStaticTagLibs

Conversation

@jdaugherty

Copy link
Copy Markdown
Contributor

Fixes #14023

@jdaugherty jdaugherty marked this pull request as ready for review May 18, 2026 05:09
@jdaugherty jdaugherty force-pushed the feature/compileStaticTagLibs branch 2 times, most recently from 14b0664 to 436b082 Compare May 18, 2026 14:03
@jdaugherty jdaugherty force-pushed the feature/compileStaticTagLibs branch from 436b082 to e5d44d2 Compare May 18, 2026 14:45

@jamesfredley jamesfredley left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +67 to +73
unresolvedVariable { VariableExpression ve ->
if (currentScope?.isController) {
currentScope.dynamicNamespaceProperties << ve
return makeDynamic(ve)
}
null
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm still investigating this one.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tested removing dynamicNamespaceProperties entirely: the tests fail because makeDynamic(ve) alone is insufficient; methodNotFound does fire on dynamic receivers and must
explicitly make the call dynamic. So the tracking is structurally required for namespace dispatch to work.

The only genuine fixes are:

  1. Whitelist known namespace names — scan all TagLib classes at compile time, collect their static namespace values, and only make variables dynamic if the name matches a
    known namespace. This is architecturally sound but requires significant infrastructure.
  2. Accept the limitation — document it (done in the Javadoc "Scope note") and rely on the fact that bookSvce.list() fails immediately at runtime with
    MissingPropertyException.

The negative tests I added do verify a meaningful guarantee: method calls on declared types still fail — bookService.nonExistentMethod() (where bookService is a declared
field) produces a compile error. The silencing only affects undeclared identifiers.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only other solution would be to somehow notify the compiler of namespaces on the classpath, which would require saving the known namespaces and accessing them in the compiler. This is probably possible, but out of scope of this change. Ithink this is a net benefit, but we probably should ticket this as an enhancement

}
}

private boolean isThisReceiver(expr) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated the javadoc. This issue exists across the Grails code base though, so we may want to consider annotating controllers so the compiler could then reliably detect a controller instead of being name driven. This probably worth a ticket.

Base automatically changed from feature/taglib-method-actions to 8.0.x May 30, 2026 15:15
  ControllerTagLibTypeCheckingExtension.groovy — 6 feedback items addressed:
  1. @SInCE 8.0 — fixed from 7.0 to match the actual 8.0.0-SNAPSHOT project version
  2. Outer scope guards — added setup { newScope() } / finish { scopeExit() } for consistency with CriteriaTypeCheckingExtension and other extensions
  3. Explicit methodNotFound types — ClassNode receiver, String name, ArgumentListExpression argList, ClassNode[] argTypes, MethodCall call
  4. Typed isThisReceiver parameter — Expression expr
  5. Explicit return null at end of run()
  6. Javadoc updates — documents both the tag-as-property limitation (def t = link compiles but throws MissingPropertyException at runtime) and the inner class naming
  behavior

  CompileStaticControllerIntegrationSpec.groovy — renamed from CompileStaticControllerSpec (both filename and class name) to eliminate the FQN collision with the unit test.

  ControllerTagLibTypeCheckingExtensionSpec.groovy (new) — 3 negative tests verifying that the extension does not suppress errors for declared types: wrong method on a
  service field, type mismatch, and wrong method on a declared local variable.
@testlens-app

testlens-app Bot commented Jun 2, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: 58acccf
▶️ Tests: 8540 executed
⚪️ Checks: 35/35 completed


Learn more about TestLens at testlens.app.

@matrei matrei self-requested a review June 3, 2026 15:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants