Skip to content

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

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

Add @GrailsCompileStatic support for controllers that call tag libs#15669
jdaugherty wants to merge 3 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.
@matrei matrei self-requested a review June 3, 2026 15:19
@codeconsole codeconsole self-requested a review June 13, 2026 18:58

@codeconsole codeconsole 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.

ControllerTagLibTypeCheckingExtension doesn't currently compose with other type-checking extensions that also resolve unrecognised method calls.

With both it and a CriteriaTypeCheckingExtension active (#15720), a controller that uses a criteria closure fails:

[Static type checking] - Reference to method is ambiguous.                                                                            
Cannot choose between [Object#eq(), Object#eq()]   // and order()
// inside a @GrailsCompileStatic controller action
ScheduledJob.createCriteria().list {
    eq 'scope', params.string('scope')   // eq/order claimed by BOTH extensions → ambiguous
    order 'nextRunAt', 'asc'
}

Both extensions register a candidate method node for the same unresolved call. It looks like the catch-all tag extension should defer (leave the call unhandled) when another extension has already resolved it, rather than both producing a node. Not a blocker for #15669's intended cases, just surfaces when it's combined with a DSL-handling extension.

@jdaugherty

Copy link
Copy Markdown
Contributor Author

@codeconsole I believe I've addressed your finding in the latest commit. Concerning the overall change, do you think we should require the registry solution to move forward with this or is this solution "good enough" that we can proceed with it? Obviously the registry solution is the "correct" solution, but it's a much larger (and impactful change) since every taglib would have to be recompiled.

@testlens-app

testlens-app Bot commented Jun 15, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: 4360166
▶️ Tests: 35720 executed
⚪️ Checks: 43/43 completed


Learn more about TestLens at testlens.app.

@codeconsole

Copy link
Copy Markdown
Contributor

@codeconsole I believe I've addressed your finding in the latest commit. Concerning the overall change, do you think we should require the registry solution to move forward with this or is this solution "good enough" that we can proceed with it? Obviously the registry solution is the "correct" solution, but it's a much larger (and impactful change) since every taglib would have to be recompiled.

Confirmed — tested the latest commit against a real CriteriaTypeCheckingExtension (not the stub) with the catch-all registered last and actual createCriteria{} closures in a @GrailsCompileStatic controller. The DYNAMIC_RESOLUTION deferral resolves it: no more ambiguous-method error.

Good enough to proceed, but the registry would be ideal. The question is whether to tackle it now in 8.x or later at another major version. This is fine for an intermediary solution.

At runtime each tag call from a controller stays a dynamic call site (the same methodMissing → tag-lookup →
captureTagOutput path used today in non-statically-compiled Groovy).

The registry solution would generate real methods, so the call becomes a direct invokevirtual — measurably faster per call.

@codeconsole codeconsole self-requested a review June 15, 2026 20:31
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.

3 participants