This is an automated email from the ASF dual-hosted git repository.

desruisseaux pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/maven-compiler-plugin.git


The following commit(s) were added to refs/heads/master by this push:
     new 6b86381  Parser for `module-info-patch.maven` files (#963)
6b86381 is described below

commit 6b86381a61bf8a2b3da6be7a3948fad606d803ba
Author: Martin Desruisseaux <[email protected]>
AuthorDate: Tue Oct 14 18:39:18 2025 +0200

    Parser for `module-info-patch.maven` files (#963)
    
    The `module-info-patch.maven` files are intended to make easier to specify 
`--add-exports` and similar options.
---
 src/it/modular-sources/pom.xml                     |   8 +-
 .../main => org.bar/main/java}/bar/App.java        |   0
 .../main => org.bar/main/java}/module-info.java    |   0
 .../test => org.bar/test/java}/bar/AppTest.java    |   0
 .../main => org.foo/main/java}/foo/App.java        |   0
 .../main => org.foo/main/java}/module-info.java    |   0
 .../test => org.foo/test/java}/foo/AppTest.java    |   0
 src/it/module-info-patch/invoker.properties        |  19 +
 .../{modular-sources => module-info-patch}/pom.xml |  12 +-
 .../src/org.bar/main/java}/bar/App.java            |   1 -
 .../src/org.bar/main/java}/module-info.java        |   0
 .../src/org.bar/test/java}/bar/AppTest.java        |   4 +
 .../src/org.bar/test/java/module-info-patch.maven} |   6 +-
 .../src/org.foo/main/java}/foo/App.java            |   0
 .../src/org.foo/main/java}/module-info.java        |   2 +-
 .../src/org.foo/test/java}/foo/AppTest.java        |   0
 .../src/org.foo/test/java/module-info-patch.maven} |  18 +-
 .../verify.groovy}                                 |  19 +-
 .../maven/plugin/compiler/ModuleInfoPatch.java     | 675 +++++++++++++++++++++
 .../plugin/compiler/ModuleInfoPatchException.java  |  50 ++
 .../maven/plugin/compiler/TestCompilerMojo.java    |   9 +-
 .../apache/maven/plugin/compiler/ToolExecutor.java |   2 +-
 .../maven/plugin/compiler/ToolExecutorForTest.java | 158 ++---
 .../maven/plugin/compiler/ModuleInfoPatchTest.java |  94 +++
 .../maven/plugin/compiler/module-info-patch.maven  |  53 ++
 25 files changed, 1015 insertions(+), 115 deletions(-)

diff --git a/src/it/modular-sources/pom.xml b/src/it/modular-sources/pom.xml
index 59b2452..a989ae2 100644
--- a/src/it/modular-sources/pom.xml
+++ b/src/it/modular-sources/pom.xml
@@ -51,20 +51,20 @@
     <sources>
       <source>
         <module>org.foo</module>
-        <directory>src/java/org.foo/main</directory>
+        <directory>src/org.foo/main/java</directory>
       </source>
       <source>
         <module>org.foo</module>
-        <directory>src/java/org.foo/test</directory>
+        <directory>src/org.foo/test/java</directory>
         <scope>test</scope>
       </source>
       <source>
         <module>org.bar</module>
-        <directory>src/java/org.bar/main</directory>
+        <directory>src/org.bar/main/java</directory>
       </source>
       <source>
         <module>org.bar</module>
-        <directory>src/java/org.bar/test</directory>
+        <directory>src/org.bar/test/java</directory>
         <scope>test</scope>
       </source>
     </sources>
diff --git a/src/it/modular-sources/src/java/org.bar/main/bar/App.java 
b/src/it/modular-sources/src/org.bar/main/java/bar/App.java
similarity index 100%
copy from src/it/modular-sources/src/java/org.bar/main/bar/App.java
copy to src/it/modular-sources/src/org.bar/main/java/bar/App.java
diff --git a/src/it/modular-sources/src/java/org.bar/main/module-info.java 
b/src/it/modular-sources/src/org.bar/main/java/module-info.java
similarity index 100%
copy from src/it/modular-sources/src/java/org.bar/main/module-info.java
copy to src/it/modular-sources/src/org.bar/main/java/module-info.java
diff --git a/src/it/modular-sources/src/java/org.bar/test/bar/AppTest.java 
b/src/it/modular-sources/src/org.bar/test/java/bar/AppTest.java
similarity index 100%
copy from src/it/modular-sources/src/java/org.bar/test/bar/AppTest.java
copy to src/it/modular-sources/src/org.bar/test/java/bar/AppTest.java
diff --git a/src/it/modular-sources/src/java/org.foo/main/foo/App.java 
b/src/it/modular-sources/src/org.foo/main/java/foo/App.java
similarity index 100%
copy from src/it/modular-sources/src/java/org.foo/main/foo/App.java
copy to src/it/modular-sources/src/org.foo/main/java/foo/App.java
diff --git a/src/it/modular-sources/src/java/org.foo/main/module-info.java 
b/src/it/modular-sources/src/org.foo/main/java/module-info.java
similarity index 100%
copy from src/it/modular-sources/src/java/org.foo/main/module-info.java
copy to src/it/modular-sources/src/org.foo/main/java/module-info.java
diff --git a/src/it/modular-sources/src/java/org.foo/test/foo/AppTest.java 
b/src/it/modular-sources/src/org.foo/test/java/foo/AppTest.java
similarity index 100%
copy from src/it/modular-sources/src/java/org.foo/test/foo/AppTest.java
copy to src/it/modular-sources/src/org.foo/test/java/foo/AppTest.java
diff --git a/src/it/module-info-patch/invoker.properties 
b/src/it/module-info-patch/invoker.properties
new file mode 100644
index 0000000..e1b3c9b
--- /dev/null
+++ b/src/it/module-info-patch/invoker.properties
@@ -0,0 +1,19 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+invoker.goals = clean compile test-compile
+invoker.buildResult = success
diff --git a/src/it/modular-sources/pom.xml b/src/it/module-info-patch/pom.xml
similarity index 87%
copy from src/it/modular-sources/pom.xml
copy to src/it/module-info-patch/pom.xml
index 59b2452..9c86d62 100644
--- a/src/it/modular-sources/pom.xml
+++ b/src/it/module-info-patch/pom.xml
@@ -20,10 +20,10 @@
 <project xmlns="http://maven.apache.org/POM/4.1.0"; 
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"; 
xsi:schemaLocation="http://maven.apache.org/POM/4.1.0 
http://maven.apache.org/xsd/maven-4.1.0.xsd";>
   <modelVersion>4.1.0</modelVersion>
   <groupId>org.apache.maven.plugins</groupId>
-  <artifactId>modular-sources</artifactId>
+  <artifactId>module-info-patch</artifactId>
   <version>1.0-SNAPSHOT</version>
   <packaging>jar</packaging>
-  <name>Modular project in Maven 4</name>
+  <name>Modular project with module-info-patch files</name>
 
   <dependencies>
     <dependency>
@@ -51,20 +51,20 @@
     <sources>
       <source>
         <module>org.foo</module>
-        <directory>src/java/org.foo/main</directory>
+        <directory>src/org.foo/main/java</directory>
       </source>
       <source>
         <module>org.foo</module>
-        <directory>src/java/org.foo/test</directory>
+        <directory>src/org.foo/test/java</directory>
         <scope>test</scope>
       </source>
       <source>
         <module>org.bar</module>
-        <directory>src/java/org.bar/main</directory>
+        <directory>src/org.bar/main/java</directory>
       </source>
       <source>
         <module>org.bar</module>
-        <directory>src/java/org.bar/test</directory>
+        <directory>src/org.bar/test/java</directory>
         <scope>test</scope>
       </source>
     </sources>
diff --git a/src/it/modular-sources/src/java/org.bar/main/bar/App.java 
b/src/it/module-info-patch/src/org.bar/main/java/bar/App.java
similarity index 97%
rename from src/it/modular-sources/src/java/org.bar/main/bar/App.java
rename to src/it/module-info-patch/src/org.bar/main/java/bar/App.java
index c2521c8..90dd182 100644
--- a/src/it/modular-sources/src/java/org.bar/main/bar/App.java
+++ b/src/it/module-info-patch/src/org.bar/main/java/bar/App.java
@@ -20,7 +20,6 @@ package bar;
 
 public class App {
     public static void main(String[] args) {
-        foo.App.main(args);
         System.out.println("Bar");
     }
 }
diff --git a/src/it/modular-sources/src/java/org.bar/main/module-info.java 
b/src/it/module-info-patch/src/org.bar/main/java/module-info.java
similarity index 100%
copy from src/it/modular-sources/src/java/org.bar/main/module-info.java
copy to src/it/module-info-patch/src/org.bar/main/java/module-info.java
diff --git a/src/it/modular-sources/src/java/org.bar/test/bar/AppTest.java 
b/src/it/module-info-patch/src/org.bar/test/java/bar/AppTest.java
similarity index 82%
rename from src/it/modular-sources/src/java/org.bar/test/bar/AppTest.java
rename to src/it/module-info-patch/src/org.bar/test/java/bar/AppTest.java
index 2192369..8bcd5b9 100644
--- a/src/it/modular-sources/src/java/org.bar/test/bar/AppTest.java
+++ b/src/it/module-info-patch/src/org.bar/test/java/bar/AppTest.java
@@ -27,5 +27,9 @@ public class AppTest {
     @Test
     public void testMain() {
         App.main(null);
+
+        // The following requires that the `foo` package from the `org.foo` 
module is exported
+        // to this `org.bar` module, which is declared in the 
`module-info-patch.maven` file.
+        foo.App.main(null);
     }
 }
diff --git a/src/it/modular-sources/src/java/org.bar/main/module-info.java 
b/src/it/module-info-patch/src/org.bar/test/java/module-info-patch.maven
similarity index 89%
rename from src/it/modular-sources/src/java/org.bar/main/module-info.java
rename to src/it/module-info-patch/src/org.bar/test/java/module-info-patch.maven
index cf8da2f..7ab7e76 100644
--- a/src/it/modular-sources/src/java/org.bar/main/module-info.java
+++ b/src/it/module-info-patch/src/org.bar/test/java/module-info-patch.maven
@@ -16,6 +16,8 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-module org.bar {
-    requires org.foo;
+
+patch-module org.bar {
+    add-modules TEST-MODULE-PATH;
+    add-reads org.junit.jupiter.api;
 }
diff --git a/src/it/modular-sources/src/java/org.foo/main/foo/App.java 
b/src/it/module-info-patch/src/org.foo/main/java/foo/App.java
similarity index 100%
rename from src/it/modular-sources/src/java/org.foo/main/foo/App.java
rename to src/it/module-info-patch/src/org.foo/main/java/foo/App.java
diff --git a/src/it/modular-sources/src/java/org.foo/main/module-info.java 
b/src/it/module-info-patch/src/org.foo/main/java/module-info.java
similarity index 92%
rename from src/it/modular-sources/src/java/org.foo/main/module-info.java
rename to src/it/module-info-patch/src/org.foo/main/java/module-info.java
index 27fa41c..ffa6d77 100644
--- a/src/it/modular-sources/src/java/org.foo/main/module-info.java
+++ b/src/it/module-info-patch/src/org.foo/main/java/module-info.java
@@ -17,5 +17,5 @@
  * under the License.
  */
 module org.foo {
-    exports foo;
+    // Do not export `foo` as we want to test `--add-opens` options.
 }
diff --git a/src/it/modular-sources/src/java/org.foo/test/foo/AppTest.java 
b/src/it/module-info-patch/src/org.foo/test/java/foo/AppTest.java
similarity index 100%
copy from src/it/modular-sources/src/java/org.foo/test/foo/AppTest.java
copy to src/it/module-info-patch/src/org.foo/test/java/foo/AppTest.java
diff --git a/src/it/modular-sources/src/java/org.foo/test/foo/AppTest.java 
b/src/it/module-info-patch/src/org.foo/test/java/module-info-patch.maven
similarity index 75%
copy from src/it/modular-sources/src/java/org.foo/test/foo/AppTest.java
copy to src/it/module-info-patch/src/org.foo/test/java/module-info-patch.maven
index db54dc7..efaa4ce 100644
--- a/src/it/modular-sources/src/java/org.foo/test/foo/AppTest.java
+++ b/src/it/module-info-patch/src/org.foo/test/java/module-info-patch.maven
@@ -16,16 +16,14 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package foo;
 
-import org.junit.jupiter.api.Test;
+patch-module org.foo {
+    add-modules TEST-MODULE-PATH;
 
-/**
- * Verifies that the compiler has access to JUnit and the main code.
- */
-public class AppTest {
-    @Test
-    public void testMain() {
-        App.main(null);
-    }
+    // Similar to `requires` in module-info.
+    // Accept also TEST-MODULE-PATH (Maven-specific).
+    add-reads org.junit.jupiter.api;
+
+    // Similar to `exports` in module-info.
+    add-exports foo to org.bar;
 }
diff --git a/src/it/modular-sources/src/java/org.foo/test/foo/AppTest.java 
b/src/it/module-info-patch/verify.groovy
similarity index 61%
rename from src/it/modular-sources/src/java/org.foo/test/foo/AppTest.java
rename to src/it/module-info-patch/verify.groovy
index db54dc7..211c143 100644
--- a/src/it/modular-sources/src/java/org.foo/test/foo/AppTest.java
+++ b/src/it/module-info-patch/verify.groovy
@@ -16,16 +16,13 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package foo;
 
-import org.junit.jupiter.api.Test;
+import java.util.jar.JarFile
 
-/**
- * Verifies that the compiler has access to JUnit and the main code.
- */
-public class AppTest {
-    @Test
-    public void testMain() {
-        App.main(null);
-    }
-}
+assert new File( basedir, "target/classes/org.foo/module-info.class").exists()
+assert new File( basedir, "target/classes/org.foo/foo/App.class").exists()
+assert new File( basedir, 
"target/test-classes/org.foo/foo/AppTest.class").exists()
+
+assert new File( basedir, "target/classes/org.bar/module-info.class").exists()
+assert new File( basedir, "target/classes/org.bar/bar/App.class").exists()
+assert new File( basedir, 
"target/test-classes/org.bar/bar/AppTest.class").exists()
diff --git 
a/src/main/java/org/apache/maven/plugin/compiler/ModuleInfoPatch.java 
b/src/main/java/org/apache/maven/plugin/compiler/ModuleInfoPatch.java
new file mode 100644
index 0000000..e2c3b3d
--- /dev/null
+++ b/src/main/java/org/apache/maven/plugin/compiler/ModuleInfoPatch.java
@@ -0,0 +1,675 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.plugin.compiler;
+
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.io.Reader;
+import java.io.StreamTokenizer;
+import java.lang.module.ModuleDescriptor;
+import java.nio.file.Path;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.StringJoiner;
+
+import org.apache.maven.api.Dependency;
+import org.apache.maven.api.services.DependencyResolverResult;
+
+/**
+ * Reader of {@value #FILENAME} files.
+ * The main options managed by this class are the options that are not defined 
by Maven dependencies.
+ * They are the options for opening or exporting packages to other modules, or 
reading more modules.
+ * The values of these options are module names or package names.
+ * This class does not manage the options for which the value is a path.
+ *
+ * <h2>Global options</h2>
+ * The {@code --add-modules} and {@code --limit-modules} options are global, 
not options defined on a per-module basis.
+ * The global aspect is handled by using shared maps for the {@link 
#addModules} and {@link #limitModules} fields.
+ * The value of {@code --add-modules} is usually controlled by the 
dependencies declared in the {@code pom.xml} file
+ * and rarely needs to be modified.
+ *
+ * @author Martin Desruisseaux
+ */
+final class ModuleInfoPatch {
+    /**
+     * Name of {@value} files that are parsed by this class.
+     */
+    public static final String FILENAME = "module-info-patch.maven";
+
+    /**
+     * Maven-specific keyword for meaning to export a package to all the test 
module path.
+     * Other keywords such as {@code "ALL-MODULE-PATH"} are understood by the 
Java compiler.
+     */
+    private static final String TEST_MODULE_PATH = "TEST-MODULE-PATH";
+
+    /**
+     * Maven-specific keyword for meaning to export a package to all other 
modules in the current Maven (sub)project.
+     * This is useful when a module contains a package of test fixtures also 
used for the tests in all other modules.
+     */
+    private static final String SUBPROJECT_MODULES = "SUBPROJECT-MODULES";
+
+    /**
+     * Special cases for the {@code --add-modules} option.
+     * The {@value #TEST_MODULE_PATH} keyword is specific to Maven.
+     * Other keywords in this set are recognized by the Java compiler.
+     */
+    private static final Set<String> ADD_MODULES_SPECIAL_CASES = 
Set.of("ALL-MODULE-PATH", TEST_MODULE_PATH);
+
+    /**
+     * Special cases for the {@code --add-exports} option.
+     * The {@value #TEST_MODULE_PATH} and {@value #SUBPROJECT_MODULES} 
keywords are specific to Maven.
+     * Other keywords in this set are recognized by the Java compiler.
+     */
+    private static final Set<String> ADD_EXPORTS_SPECIAL_CASES =
+            Set.of("ALL-UNNAMED", TEST_MODULE_PATH, SUBPROJECT_MODULES);
+
+    /**
+     * The name of the module to patch, or {@code null} if unspecified.
+     *
+     * @see #getModuleName()
+     */
+    private String moduleName;
+
+    /**
+     * Values parsed from the {@value #FILENAME} file for {@code 
--add-modules} option.
+     * A unique set is shared by {@code ModuleInfoPatch} instances of a 
project, because there
+     * is only one {@code --add-module} option applying to all modules. The 
values will be the
+     * union of the values provided by all {@value #FILENAME} files.
+     */
+    private final Set<String> addModules;
+
+    /**
+     * Values parsed from the {@value #FILENAME} file for {@code 
--limit-modules} option.
+     * A unique set is shared by all {@code ModuleInfoPatch} instances of a 
project in the
+     * same way as {@link #addModules}.
+     */
+    private final Set<String> limitModules;
+
+    /**
+     * Values parsed from the {@value #FILENAME} file for {@code --add-reads} 
option.
+     * Option values will be prefixed by {@link #moduleName}.
+     */
+    private final Set<String> addReads;
+
+    /**
+     * Values parsed from the {@value #FILENAME} file for {@code 
--add-exports} option.
+     * Option values will be prefixed by {@link #moduleName}.
+     * Keys are package names.
+     */
+    private final Map<String, Set<String>> addExports;
+
+    /**
+     * Values parsed from the {@value #FILENAME} file for {@code --add-opens} 
option.
+     * Option values will be prefixed by {@link #moduleName}.
+     * Keys are package names.
+     */
+    private final Map<String, Set<String>> addOpens;
+
+    /**
+     * A clone of this {@code ModuleInfoPatch} but with runtime dependencies 
instead of compile-time dependencies.
+     * The information saved in this object are not used by the compiler 
plugin, because the runtime dependencies
+     * may differ from the runtime dependencies. But we need to save them for 
the needs of other plugins such as
+     * Surefire. If the compile and runtime dependencies are the same, then 
the value is {@code this}.
+     */
+    private ModuleInfoPatch runtimeDependencies;
+
+    /**
+     * Creates an initially empty module patch.
+     *
+     * @param defaultModule  the name of the default module if there is no 
{@value #FILENAME}
+     * @param previous       the previous instance (for sharing global 
options), or {@code null} if none.
+     */
+    ModuleInfoPatch(String defaultModule, ModuleInfoPatch previous) {
+        if (defaultModule != null && !defaultModule.isBlank()) {
+            moduleName = defaultModule;
+        }
+        if (previous != null) {
+            addModules = previous.addModules;
+            limitModules = previous.limitModules;
+        } else {
+            addModules = new LinkedHashSet<>();
+            limitModules = new LinkedHashSet<>();
+        }
+        addReads = new LinkedHashSet<>();
+        addExports = new LinkedHashMap<>();
+        addOpens = new LinkedHashMap<>();
+        runtimeDependencies = this;
+    }
+
+    /**
+     * Creates a deep clone of the given module info patch.
+     * This is used for initializing the {@link #runtimeDependencies} field.
+     *
+     * @param parent the module info patch to clone
+     */
+    private ModuleInfoPatch(ModuleInfoPatch parent) {
+        moduleName = parent.moduleName;
+        addModules = new LinkedHashSet<>(parent.addModules);
+        limitModules = new LinkedHashSet<>(parent.limitModules);
+        addReads = new LinkedHashSet<>(parent.addReads);
+        addExports = new LinkedHashMap<>(parent.addExports);
+        addOpens = new LinkedHashMap<>(parent.addOpens);
+        // Leave `runtimeDependencies` to null as it would be an error to use 
it a second time.
+    }
+
+    /**
+     * Creates a module patch with the specified {@code --add-reads} options 
and everything else empty.
+     *
+     * @param addReads the {@code --add-reads} option
+     * @param moduleName the name of the module to patch
+     *
+     * @see #patchWithSameReads(String)
+     */
+    private ModuleInfoPatch(Set<String> addReads, String moduleName) {
+        this.moduleName = moduleName;
+        this.addReads = addReads;
+        /*
+         * Really need `Collections.emptyFoo()` here, not `Set.of()` or 
`Map.of()`.
+         * A difference is that the former silently accept calls to `clear()` 
as
+         * no-operation, while the latter throw 
`UnsupportedOperationException`.
+         */
+        addModules = Collections.emptySet();
+        limitModules = Collections.emptySet();
+        addExports = Collections.emptyMap();
+        addOpens = Collections.emptyMap();
+        // `runtimeDependencies` to be initialized by the caller.
+    }
+
+    /**
+     * Sets this instance to the default configuration to use when no {@value 
#FILENAME} is present.
+     */
+    public void setToDefaults() {
+        addModules.add(TEST_MODULE_PATH);
+        addReads.add(TEST_MODULE_PATH);
+    }
+
+    /**
+     * Loads the content of the given stream of characters.
+     * This method does not close the given reader.
+     *
+     * @param source stream of characters to read
+     * @throws IOException if an I/O error occurred while loading the file
+     */
+    public void load(Reader source) throws IOException {
+        var reader = new StreamTokenizer(source);
+        reader.slashSlashComments(true);
+        reader.slashStarComments(true);
+        expectToken(reader, "patch-module");
+        moduleName = nextName(reader, true);
+        expectToken(reader, '{');
+        while (reader.nextToken() == StreamTokenizer.TT_WORD) {
+            switch (reader.sval) {
+                case "add-modules":
+                    readModuleList(reader, addModules, 
ADD_MODULES_SPECIAL_CASES);
+                    break;
+                case "limit-modules":
+                    readModuleList(reader, limitModules, Set.of());
+                    break;
+                case "add-reads":
+                    readModuleList(reader, addReads, Set.of(TEST_MODULE_PATH));
+                    break;
+                case "add-exports":
+                    readQualified(reader, addExports, 
ADD_EXPORTS_SPECIAL_CASES);
+                    break;
+                case "add-opens":
+                    readQualified(reader, addOpens, Set.of());
+                    break;
+                default:
+                    throw new ModuleInfoPatchException("Unknown keyword \"" + 
reader.sval + '"', reader);
+            }
+        }
+        if (reader.ttype != '}') {
+            throw new ModuleInfoPatchException("Not a token", reader);
+        }
+        if (reader.nextToken() != StreamTokenizer.TT_EOF) {
+            throw new ModuleInfoPatchException("Expected end of file but found 
\"" + reader.sval + '"', reader);
+        }
+    }
+
+    /**
+     * Skips a token which is expected to be equal to the given value.
+     *
+     * @param reader the reader from which to skip a token
+     * @param expected the expected token value
+     * @throws IOException if an I/O error occurred while loading the file
+     * @throws ModuleInfoPatchException if the next token does not have the 
expected value
+     */
+    private static void expectToken(StreamTokenizer reader, String expected) 
throws IOException {
+        if (reader.nextToken() != StreamTokenizer.TT_WORD || 
!expected.equals(reader.sval)) {
+            throw new ModuleInfoPatchException("Expected \"" + expected + '"', 
reader);
+        }
+    }
+
+    /**
+     * Skips a token which is expected to be equal to the given value.
+     * The expected character must be flagged as an ordinary character in the 
reader.
+     *
+     * @param reader the reader from which to skip a token
+     * @param expected the expected character value
+     * @throws IOException if an I/O error occurred while loading the file
+     * @throws ModuleInfoPatchException if the next token does not have the 
expected value
+     */
+    private static void expectToken(StreamTokenizer reader, char expected) 
throws IOException {
+        if (reader.nextToken() != expected) {
+            throw new ModuleInfoPatchException("Expected \"" + expected + '"', 
reader);
+        }
+    }
+
+    /**
+     * Returns the next package or module name.
+     * This method verifies that the name is non-empty and a valid Java 
identifier.
+     *
+     * @param reader the reader from which to get the package or module name
+     * @param module {@code true} is expecting a module name, {@code false} if 
expecting a package name
+     * @return the package or module name
+     * @throws IOException if an I/O error occurred while loading the file
+     * @throws ModuleInfoPatchException if the next token is not a package or 
module name
+     */
+    private static String nextName(StreamTokenizer reader, boolean module) 
throws IOException {
+        if (reader.nextToken() != StreamTokenizer.TT_WORD) {
+            throw new ModuleInfoPatchException("Expected a " + (module ? 
"module" : "package") + " name", reader);
+        }
+        return ensureValidName(reader, reader.sval.strip(), module);
+    }
+
+    /**
+     * Verifies that the given name is a valid package or module identifier.
+     *
+     * @param reader the reader from which to get the line number if an 
exception needs to be thrown
+     * @param name the name to verify
+     * @param module {@code true} is expecting a module name, {@code false} if 
expecting a package name
+     * @throws ModuleInfoPatchException if the next token is not a package or 
module name
+     * @return the given name
+     */
+    private static String ensureValidName(StreamTokenizer reader, String name, 
boolean module) {
+        int length = name.length();
+        boolean expectFirstChar = true;
+        int c;
+        for (int i = 0; i < length; i += Character.charCount(c)) {
+            c = name.codePointAt(i);
+            if (expectFirstChar) {
+                if (Character.isJavaIdentifierStart(c)) {
+                    expectFirstChar = false;
+                } else {
+                    break; // Will throw exception because `expectFirstChar` 
is true.
+                }
+            } else if (!Character.isJavaIdentifierPart(c)) {
+                expectFirstChar = true;
+                if (c != '.') {
+                    break; // Will throw exception because `expectFirstChar` 
is true.
+                }
+            }
+        }
+        if (expectFirstChar) { // Also true if the name is empty
+            throw new ModuleInfoPatchException(
+                    "Invalid " + (module ? "module" : "package") + " name \"" 
+ name + '"', reader);
+        }
+        return name;
+    }
+
+    /**
+     * Reads a list of modules and stores the values in the given set.
+     *
+     * @param reader the reader from which to get the module names
+     * @param target where to store the module names
+     * @param specialCases special values to accept
+     * @return {@code target} or a new set if the target was initially null
+     * @throws IOException if an I/O error occurred while loading the file
+     * @throws ModuleInfoPatchException if the next token is not a module name
+     */
+    private static void readModuleList(StreamTokenizer reader, Set<String> 
target, Set<String> specialCases)
+            throws IOException {
+        do {
+            while (reader.nextToken() == StreamTokenizer.TT_WORD) {
+                String module = reader.sval.strip();
+                if (!specialCases.contains(module)) {
+                    module = ensureValidName(reader, module, true);
+                }
+                target.add(module);
+            }
+        } while (reader.ttype == ',');
+        if (reader.ttype != ';') {
+            throw new ModuleInfoPatchException("Missing ';' character", 
reader);
+        }
+    }
+
+    /**
+     * Reads a package name followed by a list of modules names.
+     * Used for qualified exports or qualified opens.
+     *
+     * @param reader the reader from which to get the module names
+     * @param target where to store the module names
+     * @param specialCases special values to accept
+     * @throws IOException if an I/O error occurred while loading the file
+     * @throws ModuleInfoPatchException if the next token is not a module name
+     */
+    private static void readQualified(StreamTokenizer reader, Map<String, 
Set<String>> target, Set<String> specialCases)
+            throws IOException {
+        String packageName = nextName(reader, false);
+        expectToken(reader, "to");
+        readModuleList(reader, modulesForPackage(target, packageName), 
specialCases);
+    }
+
+    /**
+     * {@return the set of modules associated to the given package name}.
+     *
+     * @param target the map where to store the set of modules
+     * @param packageName the package name for which to get a set of modules
+     */
+    private static Set<String> modulesForPackage(Map<String, Set<String>> 
target, String packageName) {
+        return target.computeIfAbsent(packageName, (key) -> new 
LinkedHashSet<>());
+    }
+
+    /**
+     * Bit mask for {@link #replaceTestModulePath(DependencyResolverResult)} 
internal usage.
+     */
+    private static final int COMPILE = 1;
+
+    /**
+     * Bit mask for {@link #replaceTestModulePath(DependencyResolverResult)} 
internal usage.
+     */
+    private static final int RUNTIME = 2;
+
+    /**
+     * Potentially adds the same value to compile and runtime sets.
+     * Whether to add a value is specified by the {@code scope} bitmask,
+     * which can contain a combination of {@link #COMPILE} and {@link 
#RUNTIME}.
+     *
+     * @param compile the collection where to add the value if the {@link 
#COMPILE} bit is set
+     * @param runtime the collection where to add the value if the {@link 
#RUNTIME} bit is set
+     * @param scope a combination of {@link #COMPILE} and {@link #RUNTIME} bits
+     * @param module the value to potentially add
+     * @return whether at least one collection has been modified
+     */
+    private static boolean addModuleName(Set<String> compile, Set<String> 
runtime, int scope, String module) {
+        boolean modified = false;
+        if ((scope & COMPILE) != 0) {
+            modified = compile.add(module);
+        }
+        if ((scope & RUNTIME) != 0 && compile != runtime) {
+            modified |= runtime.add(module);
+        }
+        return modified;
+    }
+
+    /**
+     * Potentially adds the same value to compile and runtime exports.
+     * Whether to add a value is specified by the {@code scope} bitmask,
+     * which can contain a combination of {@link #COMPILE} and {@link 
#RUNTIME}.
+     *
+     * @param packageName name of the package to export
+     * @param scope a combination of {@link #COMPILE} and {@link #RUNTIME} bits
+     * @param module the module for which to export a package
+     * @return whether at least one collection has been modified
+     */
+    private boolean addExport(String packageName, int scope, String module) {
+        Set<String> compile = modulesForPackage(addExports, packageName);
+        Set<String> runtime = compile;
+        if (runtimeDependencies != this) {
+            runtime = modulesForPackage(runtimeDependencies.addExports, 
packageName);
+        }
+        return addModuleName(compile, runtime, scope, module);
+    }
+
+    /**
+     * Replaces all occurrences of {@link #SUBPROJECT_MODULES} by the actual 
module names.
+     *
+     * @param sourceDirectories the test source directories for all modules in 
the project
+     */
+    public void replaceProjectModules(final List<SourceDirectory> 
sourceDirectories) {
+        for (Map.Entry<String, Set<String>> entry : addExports.entrySet()) {
+            if (entry.getValue().remove(SUBPROJECT_MODULES)) {
+                for (final SourceDirectory source : sourceDirectories) {
+                    final String module = source.moduleName;
+                    if (module != null && !module.equals(moduleName)) {
+                        addExport(entry.getKey(), COMPILE | RUNTIME, module);
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     * Replaces all occurrences of {@link #TEST_MODULE_PATH} by the actual 
module names.
+     * These dependencies are automatically added to the {@code --add-modules} 
option once for all modules,
+     * then added to the {@code add-reads} option if the user specified the 
{@code TEST-MODULE-PATH} value.
+     * The latter is on a per-module basis. These options are also added 
implicitly if the user did not put
+     * a {@value #FILENAME} file in the test.
+     *
+     * @param dependencyResolution the result of resolving the dependencies, 
or {@code null} if none
+     * @throws IOException if an error occurred while reading information from 
a dependency
+     */
+    public void replaceTestModulePath(final DependencyResolverResult 
dependencyResolution) throws IOException {
+        final var exportsToTestModulePath = new LinkedHashSet<String>(); // 
Packages to export.
+        for (Map.Entry<String, Set<String>> entry : addExports.entrySet()) {
+            if (entry.getValue().remove(TEST_MODULE_PATH)) {
+                exportsToTestModulePath.add(entry.getKey());
+            }
+        }
+        final boolean addAllTestModulePath = 
addModules.remove(TEST_MODULE_PATH);
+        final boolean readAllTestModulePath = 
addReads.remove(TEST_MODULE_PATH);
+        if (!addAllTestModulePath && !readAllTestModulePath && 
exportsToTestModulePath.isEmpty()) {
+            return; // Nothing to do.
+        }
+        if (dependencyResolution == null) {
+            // Note: we could log a warning, but we would need to ensure that 
it is logged only once.
+            return;
+        }
+        /*
+         * At this point, all `TEST-MODULE-PATCH` special values have been 
removed, but the actual module names
+         * have not yet been added. The module names may be added in two 
different instances. This instance is
+         * used for compile-time dependencies, while the `runtime` instance is 
used for runtime dependencies.
+         * The latter is created only if at least one dependency is different.
+         */
+        final var done = new HashMap<String, Integer>(); // Added modules and 
their dependencies.
+        for (Map.Entry<Dependency, Path> entry :
+                dependencyResolution.getDependencies().entrySet()) {
+
+            final int scope; // As a bitmask.
+            switch (entry.getKey().getScope()) {
+                case TEST:
+                    scope = COMPILE | RUNTIME;
+                    break;
+                case TEST_ONLY:
+                    scope = COMPILE;
+                    if (runtimeDependencies == this) {
+                        runtimeDependencies = new ModuleInfoPatch(this);
+                    }
+                    break;
+                case TEST_RUNTIME:
+                    scope = RUNTIME;
+                    if (runtimeDependencies == this) {
+                        runtimeDependencies = new ModuleInfoPatch(this);
+                    }
+                    break;
+                default:
+                    continue; // Skip non-test dependencies because they 
should already be in the main module-info.
+            }
+            Path dependencyPath = entry.getValue();
+            String module = 
dependencyResolution.getModuleName(dependencyPath).orElse(null);
+            if (module == null) {
+                if (readAllTestModulePath) {
+                    addModuleName(addReads, runtimeDependencies.addReads, 
scope, "ALL-UNNAMED");
+                }
+            } else if (mergeBit(done, module, scope)) {
+                boolean modified = false;
+                if (addAllTestModulePath) {
+                    modified |= addModuleName(addModules, 
runtimeDependencies.addModules, scope, module);
+                }
+                if (readAllTestModulePath) {
+                    modified |= addModuleName(addReads, 
runtimeDependencies.addReads, scope, module);
+                }
+                for (String packageName : exportsToTestModulePath) {
+                    modified |= addExport(packageName, scope, module);
+                }
+                /*
+                 * For making the options simpler, we do not add 
`--add-modules` or `--add-reads`
+                 * options for modules that are required by a module that we 
already added. This
+                 * simplification is not necessary, but makes the command-line 
easier to read.
+                 */
+                if (modified) {
+                    
dependencyResolution.getModuleDescriptor(dependencyPath).ifPresent((descriptor) 
-> {
+                        for (ModuleDescriptor.Requires r : 
descriptor.requires()) {
+                            done.merge(r.name(), scope, (o, n) -> o | n);
+                        }
+                    });
+                }
+            }
+        }
+    }
+
+    /**
+     * Sets the given bit in a map of bit masks.
+     *
+     * @param map the map where to set a bit
+     * @param key key of the entry for which to set a bit
+     * @param bit the bit to set
+     * @return whether the map changed as a result of this operation
+     */
+    private static boolean mergeBit(final Map<String, Integer> map, final 
String key, final int bit) {
+        Integer mask = map.putIfAbsent(key, bit);
+        if (mask != null) {
+            if ((mask & bit) != 0) {
+                return false;
+            }
+            map.put(key, mask | bit);
+        }
+        return true;
+    }
+
+    /**
+     * Returns a patch for another module with the same {@code --add-reads} 
options. All other options are empty.
+     * This is used when a {@code ModuleInfoPatch} instance has been created 
for the implicit options and the
+     * caller wants to replicate these default values to other modules 
declared in the {@code <sources>}.
+     *
+     * <h4>Constraint</h4>
+     * This method should be invoked <em>after</em> {@link 
#replaceTestModulePath(DependencyResolverResult)},
+     * otherwise the runtime dependencies derived from {@code 
TEST-MODULE-PaTH} may not be correct.
+     *
+     * @param otherModule the other module to patch, or {@code null} or empty 
if none
+     * @return patch for the other module, or {@code null} if {@code 
otherModule} was null or empty
+     */
+    public ModuleInfoPatch patchWithSameReads(String otherModule) {
+        if (otherModule == null || otherModule.isBlank()) {
+            return null;
+        }
+        var other = new ModuleInfoPatch(addReads, otherModule);
+        other.runtimeDependencies =
+                (runtimeDependencies == this) ? other : new 
ModuleInfoPatch(runtimeDependencies.addReads, otherModule);
+        return other;
+    }
+
+    /**
+     * {@return the name of the module to patch, or null if unspecified and no 
default}.
+     */
+    public String getModuleName() {
+        return moduleName;
+    }
+
+    /**
+     * Writes the values of the given option if the values is is non-null.
+     *
+     * @param option the option for which to write the values
+     * @param prefix prefix to write, followed by {@code '='}, before the 
value, or empty if none
+     * @param compile the values to write for the compiler, or {@code null} if 
none
+     * @param runtime the values to write for the Java launcher
+     * @param configuration where to write the option values for the compiler
+     * @param out where to write the option values for the Java launcher
+     */
+    private static void write(
+            String option,
+            String prefix,
+            Set<String> compile,
+            Set<String> runtime,
+            Options configuration,
+            BufferedWriter out)
+            throws IOException {
+        Set<String> values = runtime;
+        do {
+            if (!values.isEmpty()) {
+                var buffer = new StringJoiner(",", (prefix != null) ? prefix + 
'=' : "", "");
+                for (String value : values) {
+                    buffer.add(value);
+                }
+                if (values == compile) {
+                    configuration.addIfNonBlank("--" + option, 
buffer.toString());
+                }
+                if (values == runtime) {
+                    out.append("--").append(option).append(' 
').append(buffer.toString());
+                    out.newLine();
+                }
+            }
+        } while (values != compile && (values = compile) != null);
+    }
+
+    /**
+     * Writes options that are qualified by module name and package name.
+     *
+     * @param option the option for which to write the values
+     * @param compile the values to write for the compiler, or {@code null} if 
none
+     * @param runtime the values to write for the Java launcher
+     * @param configuration where to write the option values for the compiler
+     * @param out where to write the option values for the Java launcher
+     */
+    private void write(
+            String option,
+            Map<String, Set<String>> compile,
+            Map<String, Set<String>> runtime,
+            Options configuration,
+            BufferedWriter out)
+            throws IOException {
+        Map<String, Set<String>> values = runtime;
+        do {
+            for (Map.Entry<String, Set<String>> entry : values.entrySet()) {
+                String prefix = moduleName + '/' + entry.getKey();
+                Set<String> otherModules = entry.getValue();
+                write(
+                        option,
+                        prefix,
+                        (values == compile) ? otherModules : null,
+                        (values == runtime) ? otherModules : Set.of(),
+                        configuration,
+                        out);
+            }
+        } while (values != compile && (values = compile) != null);
+    }
+
+    /**
+     * Writes the options.
+     *
+     * @param compile where to write the compile-time options
+     * @param runtime where to write the runtime options
+     */
+    public void writeTo(final Options compile, final BufferedWriter runtime) 
throws IOException {
+        write("add-modules", null, addModules, runtimeDependencies.addModules, 
compile, runtime);
+        write("limit-modules", null, limitModules, 
runtimeDependencies.limitModules, compile, runtime);
+        if (moduleName != null) {
+            write("add-reads", moduleName, addReads, 
runtimeDependencies.addReads, compile, runtime);
+            write("add-exports", addExports, runtimeDependencies.addExports, 
compile, runtime);
+            write("add-opens", null, runtimeDependencies.addOpens, compile, 
runtime);
+        }
+        addModules.clear(); // Add modules only once (this set is shared by 
other instances).
+        limitModules.clear();
+    }
+}
diff --git 
a/src/main/java/org/apache/maven/plugin/compiler/ModuleInfoPatchException.java 
b/src/main/java/org/apache/maven/plugin/compiler/ModuleInfoPatchException.java
new file mode 100644
index 0000000..a2fc33b
--- /dev/null
+++ 
b/src/main/java/org/apache/maven/plugin/compiler/ModuleInfoPatchException.java
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.plugin.compiler;
+
+import java.io.StreamTokenizer;
+
+/**
+ * Thrown when a {@code module-info-patch.maven} file cannot be parsed.
+ *
+ * @author Martin Desruisseaux
+ */
+@SuppressWarnings("serial")
+public class ModuleInfoPatchException extends CompilationFailureException {
+    /**
+     * Creates a new exception with the given message.
+     *
+     * @param message the short message
+     */
+    public ModuleInfoPatchException(String message) {
+        super(message);
+    }
+
+    /**
+     * Creates a new exception with the given message followed by "at line" 
and the line number.
+     * This is not in public API because the use of {@link StreamTokenizer} is 
an implementation
+     * details that may change in any future version.
+     *
+     * @param message the short message
+     * @param reader the reader used for parsing the file
+     */
+    ModuleInfoPatchException(String message, StreamTokenizer reader) {
+        super(message + " at line " + reader.lineno());
+    }
+}
diff --git 
a/src/main/java/org/apache/maven/plugin/compiler/TestCompilerMojo.java 
b/src/main/java/org/apache/maven/plugin/compiler/TestCompilerMojo.java
index fa5ac6c..691b12f 100644
--- a/src/main/java/org/apache/maven/plugin/compiler/TestCompilerMojo.java
+++ b/src/main/java/org/apache/maven/plugin/compiler/TestCompilerMojo.java
@@ -201,7 +201,10 @@ public class TestCompilerMojo extends AbstractCompilerMojo 
{
      * <p>This field exists in this class only for transferring this 
information
      * to {@link ToolExecutorForTest#hasTestModuleInfo}, which is the class 
that
      * needs this information.</p>
+     *
+     * @deprecated Avoid {@code module-info.java} in tests.
      */
+    @Deprecated(since = "4.0.0")
     transient boolean hasTestModuleInfo;
 
     /**
@@ -398,10 +401,8 @@ public class TestCompilerMojo extends AbstractCompilerMojo 
{
             message.a("Overwriting the ")
                     .warning(MODULE_INFO + JAVA_FILE_SUFFIX)
                     .a(" file in the test directory is deprecated. Use ")
-                    .info("--add-reads")
-                    .a(", ")
-                    .info("--add-modules")
-                    .a(" and related options instead.");
+                    .info(ModuleInfoPatch.FILENAME)
+                    .a(" instead.");
             logger.warn(message.toString());
             if (SUPPORT_LEGACY) {
                 return useModulePath;
diff --git a/src/main/java/org/apache/maven/plugin/compiler/ToolExecutor.java 
b/src/main/java/org/apache/maven/plugin/compiler/ToolExecutor.java
index 62f40dc..1b9a20c 100644
--- a/src/main/java/org/apache/maven/plugin/compiler/ToolExecutor.java
+++ b/src/main/java/org/apache/maven/plugin/compiler/ToolExecutor.java
@@ -420,7 +420,7 @@ public class ToolExecutor {
                 }
             } else if (key instanceof JavaPathType.Modular type) {
                 /*
-                 * Source code of test classes, handled as a "dependency".
+                 * Main code to be tested by the test classes. This is handled 
as a "dependency".
                  * Placed on: --patch-module-path.
                  */
                 Optional<JavaFileManager.Location> location = 
type.rawType().location();
diff --git 
a/src/main/java/org/apache/maven/plugin/compiler/ToolExecutorForTest.java 
b/src/main/java/org/apache/maven/plugin/compiler/ToolExecutorForTest.java
index 8e4ba65..2c0fc91 100644
--- a/src/main/java/org/apache/maven/plugin/compiler/ToolExecutorForTest.java
+++ b/src/main/java/org/apache/maven/plugin/compiler/ToolExecutorForTest.java
@@ -22,21 +22,20 @@ import javax.tools.DiagnosticListener;
 import javax.tools.JavaCompiler;
 import javax.tools.JavaFileObject;
 
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.Writer;
 import java.lang.module.ModuleDescriptor;
 import java.nio.file.Files;
 import java.nio.file.Path;
-import java.util.HashSet;
 import java.util.LinkedHashMap;
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
-import java.util.StringJoiner;
 
-import org.apache.maven.api.Dependency;
 import org.apache.maven.api.JavaPathType;
 import org.apache.maven.api.PathType;
 import org.apache.maven.api.ProjectScope;
@@ -69,15 +68,21 @@ class ToolExecutorForTest extends ToolExecutor {
      * in which case the main classes are placed on the class path, but this 
is deprecated.
      * This flag may be removed in a future version if we remove support of 
this practice.
      *
+     * @deprecated Use {@code "claspath-jar"} dependency type instead, and 
avoid {@code module-info.java} in tests.
+     *
      * @see TestCompilerMojo#useModulePath
      */
+    @Deprecated(since = "4.0.0")
     private final boolean useModulePath;
 
     /**
      * Whether a {@code module-info.java} file is defined in the test sources.
      * In such case, it has precedence over the {@code module-info.java} in 
main sources.
      * This is defined for compatibility with Maven 3, but not recommended.
+     *
+     * @deprecated Avoid {@code module-info.java} in tests.
      */
+    @Deprecated(since = "4.0.0")
     private final boolean hasTestModuleInfo;
 
     /**
@@ -232,12 +237,15 @@ class ToolExecutorForTest extends ToolExecutor {
     }
 
     /**
-     * Generates the {@code --add-modules} and {@code --add-reads} options for 
the dependencies that are not
-     * in the main compilation. This method is invoked only if {@code 
hasModuleDeclaration} is {@code true}.
+     * Completes the given configuration with module options the first time 
that this method is invoked.
+     * If at least one {@value ModuleInfoPatch#FILENAME} file is found in a 
root directory of test sources,
+     * then these files are parsed and the options that they declare are added 
to the given configuration.
+     * Otherwise, if {@link #hasModuleDeclaration} is {@code true}, then this 
method generates the
+     * {@code --add-modules} and {@code --add-reads} options for dependencies 
that are not in the main compilation.
+     * If this method is invoked more than once, all invocations after the 
first one have no effect.
      *
-     * @param dependencyResolution the project dependencies
      * @param configuration where to add the options
-     * @throws IOException if the module information of a dependency cannot be 
read
+     * @throws IOException if the module information of a dependency or the 
module-info patch cannot be read
      */
     @SuppressWarnings({"checkstyle:MissingSwitchDefault", "fallthrough"})
     private void addModuleOptions(final Options configuration) throws 
IOException {
@@ -245,79 +253,79 @@ class ToolExecutorForTest extends ToolExecutor {
             return;
         }
         addedModuleOptions = true;
-        if (!hasModuleDeclaration || dependencyResolution == null) {
-            return;
-        }
-        if (SUPPORT_LEGACY && useModulePath && hasTestModuleInfo) {
-            /*
-             * Do not add any `--add-reads` parameters. The developers should 
put
-             * everything needed in the `module-info`, including test 
dependencies.
-             */
-            return;
-        }
-        final var done = new HashSet<String>(); // Added modules and their 
dependencies.
-        final var addModules = new StringJoiner(",");
-        StringJoiner addReads = null;
-        boolean hasUnnamed = false;
-        for (Map.Entry<Dependency, Path> entry :
-                dependencyResolution.getDependencies().entrySet()) {
-            boolean compile = false;
-            switch (entry.getKey().getScope()) {
-                case TEST:
-                case TEST_ONLY:
-                    compile = true;
-                    // Fall through
-                case TEST_RUNTIME:
-                    if (compile) {
-                        // Needs to be initialized even if `name` is null.
-                        if (addReads == null) {
-                            addReads = new StringJoiner(",");
-                        }
-                    }
-                    Path path = entry.getValue();
-                    String name = 
dependencyResolution.getModuleName(path).orElse(null);
-                    if (name == null) {
-                        hasUnnamed = true;
-                    } else if (done.add(name)) {
-                        addModules.add(name);
-                        if (compile) {
-                            addReads.add(name);
-                        }
-                        /*
-                         * For making the options simpler, we do not add 
`--add-modules` or `--add-reads`
-                         * options for modules that are required by a module 
that we already added. This
-                         * simplification is not necessary, but makes the 
command-line easier to read.
-                         */
-                        
dependencyResolution.getModuleDescriptor(path).ifPresent((descriptor) -> {
-                            for (ModuleDescriptor.Requires r : 
descriptor.requires()) {
-                                done.add(r.name());
-                            }
-                        });
+        ModuleInfoPatch info = null;
+        ModuleInfoPatch defaultInfo = null;
+        final var patches = new LinkedHashMap<String, ModuleInfoPatch>();
+        for (SourceDirectory source : sourceDirectories) {
+            Path file = source.root.resolve(ModuleInfoPatch.FILENAME);
+            String module;
+            if (Files.notExists(file)) {
+                if (SUPPORT_LEGACY && useModulePath && hasTestModuleInfo && 
hasModuleDeclaration) {
+                    /*
+                     * Do not add any `--add-reads` parameters. The developers 
should put
+                     * everything needed in the `module-info`, including test 
dependencies.
+                     */
+                    continue;
+                }
+                /*
+                 * No `patch-module-info` file. Generate a default module 
patch instance for the
+                 * `--add-modules TEST-MODULE-PATH` and `--add-reads 
TEST-MODULE-PATH` options.
+                 * We generate that patch only for the first module. If there 
is more modules
+                 * without `patch-module-info`, we will copy the `defaultInfo` 
instance.
+                 */
+                module = source.moduleName;
+                if (module == null) {
+                    module = getMainModuleName();
+                    if (module.isEmpty()) {
+                        continue;
                     }
-                    break;
+                }
+                if (defaultInfo != null) {
+                    patches.putIfAbsent(module, null); // Remember that we 
will need to compute a value later.
+                    continue;
+                }
+                defaultInfo = new ModuleInfoPatch(module, info);
+                defaultInfo.setToDefaults();
+                info = defaultInfo;
+            } else {
+                info = new ModuleInfoPatch(getMainModuleName(), info);
+                try (BufferedReader reader = Files.newBufferedReader(file)) {
+                    info.load(reader);
+                }
+                module = info.getModuleName();
             }
-        }
-        if (!done.isEmpty()) {
-            configuration.addIfNonBlank("--add-modules", 
addModules.toString());
-        }
-        if (addReads != null) {
-            if (hasUnnamed) {
-                addReads.add("ALL-UNNAMED");
+            if (patches.put(module, info) != null) {
+                throw new ModuleInfoPatchException("\"module-info-patch " + 
module + "\" is defined more than once.");
             }
-            String reads = addReads.toString();
-            addReads(configuration, getMainModuleName(), reads);
-            for (SourceDirectory root : sourceDirectories) {
-                addReads(configuration, root.moduleName, reads);
+        }
+        /*
+         * Replace all occurrences of `TEST-MODULE-PATH` by the actual 
dependency paths.
+         * Add `--add-modules` and `--add-reads` options with default values 
equivalent to
+         * `TEST-MODULE-PATH` for every module that do not have a 
`module-info-patch` file.
+         */
+        for (Map.Entry<String, ModuleInfoPatch> entry : patches.entrySet()) {
+            info = entry.getValue();
+            if (info != null) {
+                info.replaceProjectModules(sourceDirectories);
+                info.replaceTestModulePath(dependencyResolution);
+            } else {
+                // `defaultInfo` cannot be null if this `info` value is null.
+                entry.setValue(defaultInfo.patchWithSameReads(entry.getKey()));
             }
         }
-    }
-
-    /**
-     * Adds an {@code --add-reads} compiler option if the given module name is 
non-null and non-blank.
-     */
-    private static void addReads(Options configuration, String moduleName, 
String reads) {
-        if (moduleName != null && !moduleName.isBlank()) {
-            configuration.addIfNonBlank("--add-reads", moduleName + '=' + 
reads);
+        /*
+         * Write the runtime dependencies in the 
`META-INF/maven/module-info-patch.args` file.
+         * Note that we unconditionally write in the root output directory, 
not in the module directory,
+         * because a single option file applies to all modules.
+         */
+        if (!patches.isEmpty()) {
+            Path directory = // TODO: replace by Path.resolve(String, 
String...) with JDK22.
+                    
Files.createDirectories(outputDirectory.resolve("META-INF").resolve("maven"));
+            try (BufferedWriter out = 
Files.newBufferedWriter(directory.resolve("module-info-patch.args"))) {
+                for (ModuleInfoPatch m : patches.values()) {
+                    m.writeTo(configuration, out);
+                }
+            }
         }
     }
 
diff --git 
a/src/test/java/org/apache/maven/plugin/compiler/ModuleInfoPatchTest.java 
b/src/test/java/org/apache/maven/plugin/compiler/ModuleInfoPatchTest.java
new file mode 100644
index 0000000..3d17e3a
--- /dev/null
+++ b/src/test/java/org/apache/maven/plugin/compiler/ModuleInfoPatchTest.java
@@ -0,0 +1,94 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.plugin.compiler;
+
+import javax.tools.OptionChecker;
+
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.io.StringWriter;
+
+import org.apache.maven.api.plugin.Log;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+
+/**
+ * Tests {@link ModuleInfoPatch}.
+ *
+ * @author Martin Desruisseaux
+ */
+public class ModuleInfoPatchTest implements OptionChecker {
+    /**
+     * Test reading a file.
+     *
+     * @throws IOException if an I/O error occurred while loading the file
+     */
+    @Test
+    public void testRead() throws IOException {
+        var info = new ModuleInfoPatch(null, null);
+        try (Reader r =
+                new 
InputStreamReader(ModuleInfoPatchTest.class.getResourceAsStream("module-info-patch.maven")))
 {
+            info.load(r);
+        }
+        var config = new Options(this, Mockito.mock(Log.class));
+        var out = new StringWriter();
+        try (var buffered = new BufferedWriter(out)) {
+            info.writeTo(config, buffered);
+        }
+        assertArrayEquals(
+                new String[] {
+                    "--add-modules",
+                    "ALL-MODULE-PATH",
+                    "--limit-modules",
+                    "org.junit.jupiter.api",
+                    "--add-reads",
+                    "org.mymodule=org.junit.jupiter.api",
+                    "--add-exports",
+                    "org.mymodule/org.mypackage=org.someone,org.another",
+                    "--add-exports",
+                    "org.mymodule/org.foo=TEST-MODULE-PATH"
+                },
+                config.options.toArray());
+
+        assertArrayEquals(
+                new String[] {
+                    "--add-modules ALL-MODULE-PATH",
+                    "--limit-modules org.junit.jupiter.api",
+                    "--add-reads org.mymodule=org.junit.jupiter.api",
+                    "--add-exports 
org.mymodule/org.mypackage=org.someone,org.another",
+                    "--add-exports org.mymodule/org.foo=TEST-MODULE-PATH",
+                    "--add-opens org.mymodule/org.foo=org.junit.jupiter.api"
+                },
+                out.toString().split(System.lineSeparator()));
+    }
+
+    /**
+     * {@return the number of arguments the given option takes}.
+     *
+     * @param option an option
+     */
+    @Override
+    public int isSupportedOption(String option) {
+        return 1;
+    }
+}
diff --git 
a/src/test/resources/org/apache/maven/plugin/compiler/module-info-patch.maven 
b/src/test/resources/org/apache/maven/plugin/compiler/module-info-patch.maven
new file mode 100644
index 0000000..df2ef1a
--- /dev/null
+++ 
b/src/test/resources/org/apache/maven/plugin/compiler/module-info-patch.maven
@@ -0,0 +1,53 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/*
+ * Syntax: all keywords inside `patch-module` are Java compiler or Java 
launcher options without
+ * the leading `--` characters. Each option value ends at the `;` character, 
which is mandatory.
+ *
+ * Some options expect a value of the form 
`module/package=other-module(,other-module)*`.
+ * For these options, the `module` part will be the name immediately after 
`patch-module`
+ * and shall not be repeated inside the block. The `=` sign is replaced by the 
`to` keyword,
+ * as in `module-info.java` files.
+ *
+ * This block accepts only options that do not require a path to source or 
binary files.
+ * Options with path values should be handled as Maven dependencies or sources 
instead.
+ */
+patch-module org.mymodule {
+
+    add-modules ALL-MODULE-PATH;  // For testing purpose, but a valid value 
would rather be TEST-MODULE-PATH.
+
+    limit-modules org.junit.jupiter.api;
+
+    // Similar to `requires` in module-info.
+    // Accept also TEST-MODULE-PATH (Maven-specific).
+    add-reads org.junit.jupiter.api;
+
+    // Similar to `exports` in module-info.
+    add-exports org.mypackage
+             to org.someone,
+                org.another;
+
+    add-exports org.foo
+             to TEST-MODULE-PATH;   // Maven specific. Note: a standard 
alternative is ALL-UNNAMED.
+
+    // Not used by the compiler, but useful for test executions.
+    add-opens org.foo
+           to org.junit.jupiter.api;
+}

Reply via email to