Skip to content

Commit 174e2f2

Browse files
committed
GROOVY-11896: Support module import declarations
1 parent 2c25b58 commit 174e2f2

6 files changed

Lines changed: 247 additions & 0 deletions

File tree

src/antlr/GroovyLexer.g4

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,7 @@ INT : 'int';
454454
fragment
455455
LONG : 'long';
456456

457+
MODULE : 'module';
457458
NATIVE : 'native';
458459
NEW : 'new';
459460
NON_SEALED : 'non-sealed';

src/antlr/GroovyParser.g4

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ packageDeclaration
119119

120120
importDeclaration
121121
: annotationsOpt IMPORT STATIC? qualifiedName (DOT MUL | AS alias=identifier)?
122+
| annotationsOpt IMPORT MODULE qualifiedName
122123
;
123124

124125

@@ -1230,6 +1231,7 @@ identifier
12301231
| CapitalizedIdentifier
12311232
| AS
12321233
| IN
1234+
| MODULE
12331235
| PERMITS
12341236
| RECORD
12351237
| SEALED

src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,18 +136,31 @@
136136
import java.io.BufferedReader;
137137
import java.io.IOException;
138138
import java.lang.annotation.Annotation;
139+
import java.lang.module.FindException;
140+
import java.lang.module.ModuleDescriptor;
141+
import java.lang.module.ModuleFinder;
142+
import java.lang.module.ModuleReference;
143+
import java.net.URISyntaxException;
144+
import java.net.URL;
145+
import java.net.URLClassLoader;
146+
import java.nio.file.Files;
147+
import java.nio.file.Path;
139148
import java.util.ArrayDeque;
140149
import java.util.ArrayList;
141150
import java.util.Arrays;
151+
import java.util.Collection;
142152
import java.util.Collections;
143153
import java.util.Deque;
154+
import java.util.HashSet;
144155
import java.util.Iterator;
145156
import java.util.LinkedHashMap;
157+
import java.util.LinkedHashSet;
146158
import java.util.LinkedList;
147159
import java.util.List;
148160
import java.util.Map;
149161
import java.util.Objects;
150162
import java.util.Optional;
163+
import java.util.Set;
151164
import java.util.function.Function;
152165
import java.util.stream.Collectors;
153166

@@ -337,6 +350,11 @@ public PackageNode visitPackageDeclaration(final PackageDeclarationContext ctx)
337350
public ImportNode visitImportDeclaration(final ImportDeclarationContext ctx) {
338351
List<AnnotationNode> annotations = this.visitAnnotationsOpt(ctx.annotationsOpt());
339352

353+
if (asBoolean(ctx.MODULE())) {
354+
String moduleName = this.visitQualifiedName(ctx.qualifiedName());
355+
return expandModuleImport(moduleName, annotations, ctx);
356+
}
357+
340358
boolean hasStatic = asBoolean(ctx.STATIC());
341359
boolean hasStar = asBoolean(ctx.MUL());
342360
boolean hasAlias = asBoolean(ctx.alias);
@@ -387,6 +405,97 @@ public ImportNode visitImportDeclaration(final ImportDeclarationContext ctx) {
387405
return configureAST(importNode, ctx);
388406
}
389407

408+
/**
409+
* Expands {@code import module java.base} into star imports for all
410+
* packages exported (unqualified) by the named module.
411+
* Packages already covered by Groovy's default imports or existing
412+
* star imports are skipped to avoid redundant resolution work.
413+
* <p>
414+
* Modules are located from system modules (JDK) first, then from
415+
* modular JARs on the compilation classpath. Automatic modules
416+
* (JARs with an {@code Automatic-Module-Name} manifest entry but no
417+
* {@code module-info.class}) are supported — all packages in the JAR
418+
* are imported since automatic modules have no explicit exports.
419+
* <p>
420+
* Known differences from Java's module import behavior:
421+
* <ul>
422+
* <li>Ambiguous class names from multiple module imports silently resolve
423+
* to the last match, consistent with Groovy's existing star import
424+
* semantics. Java reports a compile-time error for such ambiguities.</li>
425+
* <li>Explicit single-type imports take priority over module-expanded
426+
* star imports (same as Java).</li>
427+
* </ul>
428+
*/
429+
private ImportNode expandModuleImport(final String moduleName, final List<AnnotationNode> annotations, final ImportDeclarationContext ctx) {
430+
ModuleFinder finder = ModuleFinder.compose(
431+
ModuleFinder.ofSystem(), classpathModuleFinder());
432+
ModuleReference moduleRef = finder.find(moduleName).orElse(null);
433+
if (moduleRef == null) {
434+
throw createParsingFailedException("Unknown module: " + moduleName, ctx);
435+
}
436+
ModuleDescriptor descriptor = moduleRef.descriptor();
437+
Set<String> skip = new HashSet<>(Arrays.asList(
438+
org.codehaus.groovy.control.ResolveVisitor.DEFAULT_IMPORTS));
439+
moduleNode.getStarImports().stream().map(ImportNode::getPackageName).forEach(skip::add);
440+
ImportNode lastImport = null;
441+
// Automatic modules have no exports — use packages() instead
442+
Collection<String> packageNames = descriptor.isAutomatic()
443+
? descriptor.packages()
444+
: descriptor.exports().stream()
445+
.filter(e -> !e.isQualified())
446+
.map(ModuleDescriptor.Exports::source)
447+
.collect(Collectors.toList());
448+
for (String pkg : packageNames) {
449+
String packageName = pkg + DOT_STR;
450+
if (!skip.contains(packageName)) {
451+
moduleNode.addStarImport(packageName, annotations);
452+
lastImport = last(moduleNode.getStarImports());
453+
skip.add(packageName);
454+
}
455+
}
456+
if (lastImport == null) {
457+
// All exported packages were already covered by existing imports
458+
lastImport = last(moduleNode.getStarImports());
459+
}
460+
return configureAST(lastImport, ctx);
461+
}
462+
463+
/**
464+
* Builds a {@link java.lang.module.ModuleFinder} that scans the compilation
465+
* classpath for modular JARs (those containing {@code module-info.class}).
466+
* Collects paths from both the compiler configuration classpath and any
467+
* URLClassLoaders in the classloader hierarchy.
468+
*/
469+
private ModuleFinder classpathModuleFinder() {
470+
Set<Path> paths = new LinkedHashSet<>();
471+
for (String entry : sourceUnit.getConfiguration().getClasspath()) {
472+
Path p = Path.of(entry);
473+
if (java.nio.file.Files.exists(p)) {
474+
paths.add(p);
475+
}
476+
}
477+
ClassLoader cl = sourceUnit.getClassLoader();
478+
while (cl != null) {
479+
if (cl instanceof URLClassLoader) {
480+
for (URL url : ((URLClassLoader) cl).getURLs()) {
481+
try {
482+
Path p = Path.of(url.toURI());
483+
if (java.nio.file.Files.exists(p)) {
484+
paths.add(p);
485+
}
486+
} catch (URISyntaxException ignore) {
487+
}
488+
}
489+
}
490+
cl = cl.getParent();
491+
}
492+
try {
493+
return ModuleFinder.of(paths.toArray(Path[]::new));
494+
} catch (java.lang.module.FindException e) {
495+
return ModuleFinder.of();
496+
}
497+
}
498+
390499
private static AnnotationNode makeAnnotationNode(final Class<? extends Annotation> type) {
391500
AnnotationNode node = new AnnotationNode(ClassHelper.make(type));
392501
// TODO: source offsets

src/test/groovy/groovy/ImportTest.groovy

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,4 +163,91 @@ final class ImportTest {
163163
Entry entry = [foo:'bar'].entrySet().first()
164164
'''
165165
}
166+
167+
// module imports
168+
169+
@Test
170+
void testImportModuleJavaBase() {
171+
assertScript '''
172+
import module java.base
173+
174+
// java.util
175+
List list = [1, 2, 3]
176+
Map map = [a: 1]
177+
178+
// java.io
179+
File f = new File('.')
180+
181+
// java.net
182+
URI uri = new URI('http://example.com')
183+
184+
// java.time
185+
LocalDate date = LocalDate.now()
186+
187+
// java.util.concurrent
188+
CountDownLatch latch = new CountDownLatch(1)
189+
190+
assert list.size() == 3
191+
'''
192+
}
193+
194+
@Test
195+
void testImportModuleJavaSql() {
196+
assertScript '''
197+
import module java.sql
198+
199+
// java.sql package
200+
assert Connection.name == 'java.sql.Connection'
201+
assert DriverManager.name == 'java.sql.DriverManager'
202+
'''
203+
}
204+
205+
@Test
206+
void testImportModuleFromClasspath() {
207+
// Find JUnit JAR from the test classloader and add it to the compiler classpath
208+
def junitUrl = Test.class.protectionDomain.codeSource.location
209+
def config = new org.codehaus.groovy.control.CompilerConfiguration()
210+
config.classpathList = [new File(junitUrl.toURI()).path]
211+
def loader = new GroovyClassLoader(getClass().classLoader, config)
212+
def shell = new GroovyShell(loader)
213+
shell.evaluate '''
214+
import module org.junit.jupiter.api
215+
216+
// org.junit.jupiter.api package
217+
assert Test.name == 'org.junit.jupiter.api.Test'
218+
assert Assertions.name == 'org.junit.jupiter.api.Assertions'
219+
assert DisplayName.name == 'org.junit.jupiter.api.DisplayName'
220+
assert DisabledIf.name == 'org.junit.jupiter.api.condition.DisabledIf'
221+
'''
222+
}
223+
224+
@Test
225+
void testImportModuleUnknown() {
226+
shouldFail '''
227+
import module no.such.module
228+
'''
229+
}
230+
231+
@Test
232+
void testImportModuleStarNotAllowed() {
233+
shouldFail '''
234+
import module java.base.*
235+
'''
236+
}
237+
238+
@Test
239+
void testImportModuleAliasNotAllowed() {
240+
shouldFail '''
241+
import module java.base as jb
242+
'''
243+
}
244+
245+
@Test
246+
void testModuleAsIdentifier() {
247+
// 'module' can still be used as a variable name
248+
assertScript '''
249+
def module = 'hello'
250+
assert module.toUpperCase() == 'HELLO'
251+
'''
252+
}
166253
}

subprojects/groovy-jmx/build.gradle

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,8 @@ dependencies {
3232
testRuntimeOnly projects.groovyGrapeIvy
3333
testRuntimeOnly projects.groovySwing
3434
}
35+
36+
tasks.named('test') {
37+
dependsOn 'jarjar'
38+
systemProperty 'groovy.jmx.jar', tasks.named('jarjar').flatMap { it.outputFile }.get().asFile.path
39+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package groovy.jmx
20+
21+
import org.junit.jupiter.api.Test
22+
23+
import static groovy.test.GroovyAssert.assertScript
24+
25+
final class ImportModuleJmxTest {
26+
27+
@Test
28+
void testImportModuleAutomatic() {
29+
// groovy-jmx is an automatic module (Automatic-Module-Name in MANIFEST.MF);
30+
// JAR path is passed as a system property from build.gradle
31+
def jmxJar = System.getProperty('groovy.jmx.jar')
32+
assert jmxJar, 'groovy.jmx.jar system property not set'
33+
def config = new org.codehaus.groovy.control.CompilerConfiguration()
34+
config.classpathList = [jmxJar]
35+
def loader = new GroovyClassLoader(getClass().classLoader, config)
36+
def shell = new GroovyShell(loader)
37+
shell.evaluate '''
38+
import module org.apache.groovy.jmx
39+
40+
assert GroovyMBean.name == 'groovy.jmx.GroovyMBean'
41+
'''
42+
}
43+
}

0 commit comments

Comments
 (0)