Skip to content

GROOVY-9273 GROOVY-9668: support JPMS module declarations (module-info) - #3235

Open
daniellansun wants to merge 7 commits into
apache:masterfrom
daniellansun:GROOVY-9273
Open

daniellansun wants to merge 7 commits into
apache:masterfrom
daniellansun:GROOVY-9273

Conversation

@daniellansun

@daniellansun daniellansun commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

GROOVY-9273 / GROOVY-9668: support JPMS module declarations (module-info)

Summary

Groovy now accepts a modular compilation unit (module-info.groovy or module-info.java) and compiles it to a real module-info.class (JVMS §4.7.25), following JLS §7.7.

  • Grammar: {Annotation} [open] module Identifier{.Identifier} { directives }, with imports allowed before the declaration (JLS §7.3). A newline is allowed after module. requires, exports, opens, uses, provides, to, with, transitive, and open are contextual identifiers inside that file only, so existing Groovy code that uses those names is unchanged.
  • Directives: requires [transitive] [static] (either modifier order), exports/opens with an optional to clause, uses, and provides … with one or more providers.
  • Checks: duplicate requires/exports/opens/uses/provides, duplicate to / with targets, duplicate modifiers, opens in an open module, requires static java.base (illegal), requires transitive java.base (allowed at class file version 25+), requires in module java.base, self-requires and resolution failures (missing module, cycle), exports of a package with no compilation unit in the module, enum services, provider visibility / nesting / module membership, provider constructor or public static provider() method, unnamed packages in a named module, and readability of service and annotation types. An absent requires java.base is emitted as ACC_MANDATED.
  • import module (GROOVY-11896) is unchanged in ordinary compilation units and may appear before the module declaration.
  • Joint compilation routes module-info.java through Groovy so javac stays in classpath mode for sibling .java files. After javac, only class files written by that invocation are recorded as members of the module; leftovers in a reused target directory are ignored. The collection step is overridable so a joint compiler that keeps javac class output off the filesystem can record the same names. Module graph resolution uses Configuration.resolve rather than the host boot layer, and requires static is readable at compile time. Split-package checks on a named module consider only packages it exports to the module being compiled.

module-info.class is written with ASM ClassWriter.visitModule: requires flags TRANSITIVE / STATIC_PHASE, exports and opens (including qualified targets), uses, provides, concealed packages in ModulePackages, and module annotations (ElementType.MODULE), including Groovydoc when present.

Tests

./gradlew :test --tests org.apache.groovy.jpms.ModuleInfoTest --tests org.apache.groovy.classgen.ModuleInfoVerifierEdgeCasesTest --tests org.codehaus.groovy.tools.stubgenerator.Groovy9273 --tests org.apache.groovy.parser.antlr4.ParserNegativeSyntaxTest

The suite covers directive round-trips via ModuleDescriptor.read, ServiceLoader, requires transitive readability, automatic modules (Automatic-Module-Name), qualified exports, java --describe-module, jdeps, joint compilation (including a leftover class in a reused target directory), and two automatic modules that share a package.

@codecov-commenter

codecov-commenter commented Nov 6, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.55835% with 100 lines in your changes missing coverage. Please review.
✅ Project coverage is 74.5805%. Comparing base (6b9812e) to head (c106762).
⚠️ Report is 7 commits behind head on master.

Files with missing lines Patch % Lines
...org/apache/groovy/classgen/ModuleInfoVerifier.java 89.7384% 31 Missing and 20 partials ⚠️
...s/groovy/tools/javac/JavaAwareCompilationUnit.java 69.0266% 21 Missing and 14 partials ⚠️
...va/org/apache/groovy/parser/antlr4/AstBuilder.java 87.9518% 6 Missing and 4 partials ⚠️
...main/java/org/codehaus/groovy/ast/CompileUnit.java 84.6154% 1 Missing and 1 partial ⚠️
...rg/codehaus/groovy/classgen/AsmClassGenerator.java 97.7273% 1 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##               master      #3235        +/-   ##
==================================================
+ Coverage     74.5006%   74.5805%   +0.0799%     
- Complexity      44947      45273       +326     
==================================================
  Files            1703       1705         +2     
  Lines          152337     153205       +868     
  Branches        29187      29412       +225     
==================================================
+ Hits           113492     114261       +769     
- Misses          28413      28469        +56     
- Partials        10432      10475        +43     
Files with missing lines Coverage Δ
...ain/java/org/apache/groovy/ast/ModuleInfoNode.java 100.0000% <100.0000%> (ø)
...n/java/org/codehaus/groovy/ast/AnnotationNode.java 73.6434% <100.0000%> (+0.2059%) ⬆️
...org/codehaus/groovy/classgen/ExtendedVerifier.java 94.7735% <100.0000%> (+0.0552%) ⬆️
...a/org/codehaus/groovy/control/CompilationUnit.java 80.9074% <100.0000%> (+0.1455%) ⬆️
...rg/codehaus/groovy/control/ModuleImportHelper.java 85.0000% <ø> (+10.0000%) ⬆️
...codehaus/groovy/tools/javac/JavaStubGenerator.java 86.4754% <100.0000%> (+0.0370%) ⬆️
...main/java/org/codehaus/groovy/ast/CompileUnit.java 91.1392% <84.6154%> (-1.2850%) ⬇️
...rg/codehaus/groovy/classgen/AsmClassGenerator.java 86.7422% <97.7273%> (+0.6841%) ⬆️
...va/org/apache/groovy/parser/antlr4/AstBuilder.java 87.3354% <87.9518%> (+0.1087%) ⬆️
...s/groovy/tools/javac/JavaAwareCompilationUnit.java 78.1915% <69.0266%> (-13.9138%) ⬇️
... and 1 more

... and 15 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@daniellansun
daniellansun force-pushed the GROOVY-9273 branch 3 times, most recently from b6c6f64 to e8e63bb Compare November 6, 2026 04:13

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

why do we want to solve this using node meta data? Does this not impact the joint compiler?

@daniellansun

Copy link
Copy Markdown
Contributor Author

The module declaration is not a class (JLS §7.3, §7.7), and the per-class pipeline is what we needed to reuse. visitModuleDeclaration builds a ModuleInfoNode for the directives, then a carrier ClassNode named module-info (ACC_ABSTRACT | ACC_INTERFACE) so resolution, annotation checking, and class writing already have a class to visit. The declaration itself has no field on ClassNode. Groovy's ModuleNode is the source unit, so it is the wrong place to hang a JPMS module. putNodeMetaData(ModuleInfoNode.class, declaration) is how ModuleInfoVerifier and AsmClassGenerator.emitModuleInfo find the directives; emission then writes a real ACC_MODULE class (module-info.class, JVMS §4.7.25) and does not write the interface. Annotations are also copied onto the carrier so ExtendedVerifier can check ElementType.MODULE.

That does affect the joint compiler, and the stub generator is wrong for this carrier today.

JavaAwareCompilationUnit runs JavaStubGenerator.generateClass at CONVERSION for every non-private top-level class once any .java file is in the compile. The carrier is that kind of class. The generator never reads node metadata. It already special-cases a class named package-info and emits only a package statement; it has no such case for module-info. generateStubContent therefore prints a type, approximately:

interface module-info {
}

into module-info.java (createJavaStubFile uses the class name). Two problems follow. module-info is not a Java identifier, and JLS §7.3 makes module-info.java a modular compilation unit: javac requires a module declaration there, not a type. Joint compilation of module-info.groovy together with any .java file fails at javac. Compiling the .groovy file alone does not generate stubs, and ordinary Groovy classes in a joint compile do not carry this metadata, so they are unaffected.

Metadata is not dropped inside this compilation unit. The stub generator reads the same primary ClassNode it was given; emitModuleInfo still sees ModuleInfoNode.of later. The failure is that the stubber never looks at the key, so it treats the carrier as an interface. A copy that does not call copyNodeMetaData (for example ClassNode.getPlainNodeReference) would also lose the declaration and then really emit an interface class file.

What should change: JavaStubGenerator.generateClass should skip a class for which ModuleInfoNode.of(classNode) is non-null, the same way it refuses to emit a type for package-info. Sibling .java files in this joint compiler are compiled from the classpath, not as a module-source-path compile, so javac does not need a module-info.java stub to compile them. Groovy still writes module-info.class at class generation. If we would rather have javac own the descriptor, the stub has to be the JLS §7.7 declaration transcribed from ModuleInfoNode, and Groovy must not write module-info.class as well. Either fix is a special case in the stub generator; leaving the carrier as an ordinary interface is what breaks the joint compile.

I will skip that carrier in JavaStubGenerator.generateClass, the same way package-info is special-cased, and add a joint-compilation test.

Parse and verify module-info aligned with javac 21: missing requires-static
is an error; lone transitive is a module name; empty exports/opens packages
are diagnosed; split packages, enum provides, and implicit requires java.base
(ACC_MANDATED) match javac. Rename ModuleDeclarationCoverageTest to
ModuleDeclarationVerifierEdgeCasesTest.

Close GroovyClassLoader in ModuleDeclarationTest.compile() finally so Windows
releases @tempdir JARs (demo-auto / cycle-b) before cleanup.

Rename module-info AST types ModuleDeclarationNode/Verifier to
ModuleInfoNode/ModuleInfoVerifier (org.codehaus.groovy.ast.ModuleNode and
parser production moduleDeclaration unchanged).
JavaStubGenerator.generateClass skips the synthetic module-info carrier,
so joint compilation does not write an illegal interface module-info.java.
Sibling .java files are compiled from the classpath. Groovy still writes
module-info.class.
…pe name

Parse module-info through a separate start rule so compilationUnit stays
a single alternative. The two-way decision at compilationUnit was
raising NoViableAltException from 'import' through 'long', so
`import long` reported Unexpected input: 'import long' instead of
'long' (GROOVY-12695).

Also import groovy.io.FileType in the GROOVY-9273 stub test to satisfy
CodeNarc UnnecessaryFullyQualifiedName.
…e resolution

Address the Carbon review findings for PR apache#3235 that hold up against
JLS §7.4-§7.7, JVMS §4.7.25-§4.7.26, and JEP 511, without weakening
legal tests including GROOVY-12695.

Joint compilation now indexes javac output so Java-only packages and
providers belong to the current module, and module-info.java is parsed
by Groovy rather than switching javac into single-module mode.

Module graph resolution is decoupled from the host boot layer, uses
Configuration.resolve, and treats requires static as readable at
compile time. Split-package checks only consider packages exported to
the current module. uses and provides resolve nested types, honor
import shadowing, and accept static member-type imports.

Providers are verified after default-argument synthesis. Unnamed
packages, non-public enclosing types, and primitive provider() return
types are rejected. The emitter fills ModulePackages from joint
classes, keeps provider indices aligned, and records requires
transitive java.base at class file version 25+.

The module header allows a newline after `module`, Groovydoc is
retained, and test ModuleFinders are passed as check() parameters.
A reused target directory can contain leftover .class files. Walking the
entire tree after javac treated those leftovers as members of the module
being compiled, which could falsely satisfy exports, pollute
ModulePackages, or raise split-package and unnamed-package errors.

Snapshot the target tree before javac and record new or replaced class
files, plus inner classes of the just-compiled Java sources. A walk
failure is reported and aborts compilation instead of leaving Java-only
packages unindexed.

Tests cover a stale com.stale.Old.class that must not satisfy
exports com.stale, and two unrelated automatic JARs that share a
package and provide a used service, which Configuration.resolve must
not reject.
indexCompiledClassFiles was private and skipped work when no target
directory was present, so a later joint compiler that keeps javac
CLASS_OUTPUT off the filesystem would not record Java-only packages
for module verification or ModulePackages.

Snapshot the output before javac and collect binary names afterwards
behind protected methods. The default still compares on-disk class
file stamps and ignores leftovers in a reused target directory. A
subclass that stores class bytes elsewhere, for example in memory,
can override the collect hook; names are still recorded through
CompileUnit.addCompiledClassName. The gotoPhase path always calls
the hooks, even when there is no target directory.

A test shows a subclass can satisfy exports com.hooked without a
usable target-directory snapshot, while the default path still
rejects a leftover class that javac did not write.
…-V25 target

testNegativeRequires used the host-default CompilerConfiguration for
"requires transitive java.base". On JDK 25 the default target bytecode
is V25, where that modifier is legal (JEP 511), so fail() threw
"expected compilation to fail for base-mod" in CI.

Pin that negative case to CompilerConfiguration.JDK17, the project
minimum, so the "requires java.base must not have modifiers" error is
still asserted on every CI JDK. The legal V25 case remains covered by
testRequiresTransitiveJavaBaseOnTarget25. requires static java.base
stays illegal in every version and is still checked on the host default.
@testlens-app

testlens-app Bot commented Nov 15, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: c106762
▶️ Tests: 132283 executed
⚪️ Checks: 27/27 completed


Learn more about TestLens at testlens.app/docs.

This branch has not been deployed

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