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 7c300a3  Refactoring of the handling of `--patch-module` (#1002)
7c300a3 is described below

commit 7c300a3509efdd9796e236d94bb9192dda567ebd
Author: Martin Desruisseaux <[email protected]>
AuthorDate: Thu Dec 25 19:20:36 2025 +0100

    Refactoring of the handling of `--patch-module` (#1002)
    
    Bug fixes in the handling of `--patch-module` option for tests and for 
multi-release projects:
    
    * Fix a compilation errors when a module exists only for a Java version 
higher than the base version.
    * Add the base version in the module path for avoiding recompilation of 
classes of the base version.
    * Determine the modules to patch by scanning the output directory instead 
of the <source> elements.
    * Change dependency collections from List to Deque for efficient 
prepending/removal operations.
    * Enable a test which was previously disabled because of toolchain.
    * Remove the hack that consisted in temporarily delete `module-info.class` 
when overwriting that file.
    * Refactor `compile()` method for better readability, with sub-tasks moved 
to helper methods.
    * Add `DirectoryHierarchy` enumeration in replacement of boolean flags.
    * Separate the handling of classpath project and modular project cases in 
two inner classes.
    * Refactor `WorkaroundForPatchModule` for making the workaround less 
intrusive in main code.
    ---------
    Co-authored-by: Gerd Aschemann <[email protected]>
    Co-authored-by: Claude Opus 4.5 <[email protected]>
---
 .../invoker.properties                             |  18 -
 src/it/MCOMPILER-275_separate-moduleinfo/pom.xml   |   7 +-
 .../verify.groovy                                  |  14 +-
 src/it/multirelease-with-modules/verify.groovy     |   6 +
 .../plugin/compiler/AbstractCompilerMojo.java      |   7 +-
 .../plugin/compiler/CompilationTaskSources.java    |  57 --
 .../apache/maven/plugin/compiler/CompilerMojo.java |   5 +-
 .../maven/plugin/compiler/DiagnosticLogger.java    |  12 +-
 .../maven/plugin/compiler/DirectoryHierarchy.java  | 125 ++++
 .../maven/plugin/compiler/ForkedToolSources.java   |  23 +-
 .../maven/plugin/compiler/IncrementalBuild.java    |   7 +-
 .../plugin/compiler/ModuleDirectoryRemover.java    |   3 +
 .../maven/plugin/compiler/SourceDirectory.java     |  42 +-
 .../maven/plugin/compiler/SourcesForRelease.java   |  34 +-
 .../maven/plugin/compiler/TestCompilerMojo.java    |  51 +-
 .../apache/maven/plugin/compiler/ToolExecutor.java | 652 +++++++++++++++------
 .../maven/plugin/compiler/ToolExecutorForTest.java | 338 ++++++-----
 .../plugin/compiler/WorkaroundForPatchModule.java  | 442 +++++++++-----
 18 files changed, 1159 insertions(+), 684 deletions(-)

diff --git a/src/it/MCOMPILER-275_separate-moduleinfo/invoker.properties 
b/src/it/MCOMPILER-275_separate-moduleinfo/invoker.properties
deleted file mode 100644
index ddb00e6..0000000
--- a/src/it/MCOMPILER-275_separate-moduleinfo/invoker.properties
+++ /dev/null
@@ -1,18 +0,0 @@
-# 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.toolchain.jdk.version=1.9
diff --git a/src/it/MCOMPILER-275_separate-moduleinfo/pom.xml 
b/src/it/MCOMPILER-275_separate-moduleinfo/pom.xml
index 27e28c6..955b57e 100644
--- a/src/it/MCOMPILER-275_separate-moduleinfo/pom.xml
+++ b/src/it/MCOMPILER-275_separate-moduleinfo/pom.xml
@@ -49,9 +49,6 @@
             <id>default-compile</id>
             <!-- compile everything to ensure module-info contains right 
entries -->
             <configuration>
-              <jdkToolchain>
-                <version>1.9</version>
-              </jdkToolchain>
               <release>17</release>
             </configuration>
           </execution>
@@ -64,9 +61,7 @@
               <excludes>
                 <exclude>module-info.java</exclude>
               </excludes>
-              <!-- ideally this would be 1.5, but with CI's it is hard to have 
a proper toolchains.xml in place -->
-              <source>8</source>
-              <target>8</target>
+              <release>16</release>
             </configuration>
           </execution>
         </executions>
diff --git a/src/it/MCOMPILER-275_separate-moduleinfo/verify.groovy 
b/src/it/MCOMPILER-275_separate-moduleinfo/verify.groovy
index 8bab980..1e7e2bf 100644
--- a/src/it/MCOMPILER-275_separate-moduleinfo/verify.groovy
+++ b/src/it/MCOMPILER-275_separate-moduleinfo/verify.groovy
@@ -18,13 +18,7 @@
  */
 def log = new File( basedir, 'build.log').text
 
-assert log.count( "[INFO] Toolchain in maven-compiler-plugin: JDK" ) == 1
-
-assert log.count( "[INFO] Recompiling the module because of changed source 
code." ) == 1
-assert log.count( "[INFO] Recompiling the module because of added or removed 
source files." ) == 1
-assert log.count( "[INFO] Recompiling the module because of changed 
dependency." ) == 1
-
-// major_version: 52 = java8 -> execution id "base-compile"
-assert new File( basedir, 'target/classes/com/foo/MyClass.class' ).bytes[7] == 
52
-// major_version: 53 = java9 -> execution id "default-compile"
-assert new File( basedir, 'target/classes/module-info.class' ).bytes[7] == 53
+// major_version: 60 = java 16 -> execution id "base-compile"
+assert new File( basedir, 'target/classes/com/foo/MyClass.class' ).bytes[7] == 
60
+// major_version: 61 = java 17 -> execution id "default-compile"
+assert new File( basedir, 'target/classes/module-info.class' ).bytes[7] == 61
diff --git a/src/it/multirelease-with-modules/verify.groovy 
b/src/it/multirelease-with-modules/verify.groovy
index 146fa96..ee31cf7 100644
--- a/src/it/multirelease-with-modules/verify.groovy
+++ b/src/it/multirelease-with-modules/verify.groovy
@@ -30,6 +30,12 @@ assert baseVersion == getMajor(new File( basedir, 
"target/classes/foo.bar.more/m
 assert nextVersion == getMajor(new File( basedir, 
"target/classes/META-INF/versions-modular/16/foo.bar/foo/OtherFile.class"))
 assert nextVersion == getMajor(new File( basedir, 
"target/classes/META-INF/versions-modular/16/foo.bar.more/more/OtherFile.class"))
 
+// Verify that the classes inherited from the base version were not recompiled 
a second time.
+assert new File( basedir, 
"target/classes/META-INF/versions-modular/16/foo.bar/foo/MainFile.class").exists()
 == false
+assert new File( basedir, 
"target/classes/META-INF/versions-modular/16/foo.bar/foo/YetAnotherFile.class").exists()
 == false
+assert new File( basedir, 
"target/classes/META-INF/versions-modular/16/foo.bar.more/more/MainFile.class").exists()
 == false
+
+
 int getMajor(File file)
 {
   assert file.exists()
diff --git 
a/src/main/java/org/apache/maven/plugin/compiler/AbstractCompilerMojo.java 
b/src/main/java/org/apache/maven/plugin/compiler/AbstractCompilerMojo.java
index d52aff1..c00d35a 100644
--- a/src/main/java/org/apache/maven/plugin/compiler/AbstractCompilerMojo.java
+++ b/src/main/java/org/apache/maven/plugin/compiler/AbstractCompilerMojo.java
@@ -1523,7 +1523,10 @@ public abstract class AbstractCompilerMojo implements 
Mojo {
      *
      * @param source the source file to parse (may be null or not exist)
      * @return the module name, or {@code null} if not found
+     *
+     * @deprecated This is invoked only by other deprecated methods.
      */
+    @Deprecated(since = "4.0.0")
     final String parseModuleInfoName(Path source) throws IOException {
         if (source != null && Files.exists(source)) {
             Charset charset = charset();
@@ -1619,7 +1622,7 @@ public abstract class AbstractCompilerMojo implements 
Mojo {
      */
     @Deprecated(since = "4.0.0")
     @SuppressWarnings("UseSpecificCatch")
-    final void resolveProcessorPathEntries(Map<PathType, List<Path>> addTo) 
throws MojoException {
+    final void resolveProcessorPathEntries(Map<PathType, Collection<Path>> 
addTo) throws MojoException {
         List<DependencyCoordinate> dependencies = annotationProcessorPaths;
         if (!isAbsent(dependencies)) {
             try {
@@ -1826,7 +1829,7 @@ public abstract class AbstractCompilerMojo implements 
Mojo {
             try (BufferedWriter out = Files.newBufferedWriter(pathForRelease)) 
{
                 configuration.setRelease(sources.getReleaseString());
                 configuration.format((i == indexToShow) ? commandLine : null, 
out);
-                for (Map.Entry<PathType, List<Path>> entry : 
sources.dependencySnapshot.entrySet()) {
+                for (Map.Entry<PathType, Collection<Path>> entry : 
sources.dependencySnapshot.entrySet()) {
                     writeOption(out, entry.getKey(), entry.getValue());
                 }
                 for (Map.Entry<String, Set<Path>> root : 
sources.roots.entrySet()) {
diff --git 
a/src/main/java/org/apache/maven/plugin/compiler/CompilationTaskSources.java 
b/src/main/java/org/apache/maven/plugin/compiler/CompilationTaskSources.java
deleted file mode 100644
index 69146a7..0000000
--- a/src/main/java/org/apache/maven/plugin/compiler/CompilationTaskSources.java
+++ /dev/null
@@ -1,57 +0,0 @@
-/*
- * 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.JavaCompiler;
-
-import java.io.IOException;
-import java.nio.file.Path;
-import java.util.List;
-
-/**
- * Source files to compile in a single compilation task.
- * This is simply a list of files, together with an optional task to execute 
before and after compilation.
- */
-class CompilationTaskSources {
-    /**
-     * All source files to compile;
-     */
-    final List<Path> files;
-
-    /**
-     * Creates a new compilation task.
-     *
-     * @param files the files to compile.
-     */
-    CompilationTaskSources(List<Path> files) {
-        this.files = files;
-    }
-
-    /**
-     * Executes the compilation task. Subclasses can override this method is 
they need to perform
-     * pre-compilation or post-compilation tasks.
-     *
-     * @param  task the compilation task
-     * @return whether the compilation was successful.
-     * @throws IOException if an initialization or cleaner task was required 
and failed.
-     */
-    boolean compile(JavaCompiler.CompilationTask task) throws IOException {
-        return task.call();
-    }
-}
diff --git a/src/main/java/org/apache/maven/plugin/compiler/CompilerMojo.java 
b/src/main/java/org/apache/maven/plugin/compiler/CompilerMojo.java
index 373f073..425f16d 100644
--- a/src/main/java/org/apache/maven/plugin/compiler/CompilerMojo.java
+++ b/src/main/java/org/apache/maven/plugin/compiler/CompilerMojo.java
@@ -267,7 +267,8 @@ public class CompilerMojo extends AbstractCompilerMojo {
     @Override
     protected Path getOutputDirectory() {
         if (SUPPORT_LEGACY && multiReleaseOutput && release != null) {
-            return SourceDirectory.outputDirectoryForReleases(false, 
outputDirectory)
+            return DirectoryHierarchy.PACKAGE
+                    .outputDirectoryForReleases(outputDirectory)
                     .resolve(release);
         }
         return outputDirectory;
@@ -341,7 +342,7 @@ public class CompilerMojo extends AbstractCompilerMojo {
      */
     @Deprecated(since = "4.0.0")
     private TreeMap<SourceVersion, Path> getOutputDirectoryPerVersion() throws 
IOException {
-        final Path root = SourceDirectory.outputDirectoryForReleases(false, 
outputDirectory);
+        final Path root = 
DirectoryHierarchy.PACKAGE.outputDirectoryForReleases(outputDirectory);
         if (Files.notExists(root)) {
             return null;
         }
diff --git 
a/src/main/java/org/apache/maven/plugin/compiler/DiagnosticLogger.java 
b/src/main/java/org/apache/maven/plugin/compiler/DiagnosticLogger.java
index 6c1b92f..9bce7a5 100644
--- a/src/main/java/org/apache/maven/plugin/compiler/DiagnosticLogger.java
+++ b/src/main/java/org/apache/maven/plugin/compiler/DiagnosticLogger.java
@@ -97,12 +97,14 @@ final class DiagnosticLogger implements 
DiagnosticListener<JavaFileObject> {
      * @return the given path, potentially relative to the base directory
      */
     private String relativize(String file) {
-        try {
-            return directory.relativize(Path.of(file)).toString();
-        } catch (IllegalArgumentException e) {
-            // Ignore, keep the absolute path.
-            return file;
+        if (directory != null) {
+            try {
+                return directory.relativize(Path.of(file)).toString();
+            } catch (IllegalArgumentException e) {
+                // Ignore, keep the absolute path.
+            }
         }
+        return file;
     }
 
     /**
diff --git 
a/src/main/java/org/apache/maven/plugin/compiler/DirectoryHierarchy.java 
b/src/main/java/org/apache/maven/plugin/compiler/DirectoryHierarchy.java
new file mode 100644
index 0000000..4175714
--- /dev/null
+++ b/src/main/java/org/apache/maven/plugin/compiler/DirectoryHierarchy.java
@@ -0,0 +1,125 @@
+/*
+ * 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.lang.model.SourceVersion;
+
+import java.nio.file.Path;
+import java.util.Locale;
+
+/**
+ * The way that source files are organized in a file system directory 
hierarchy.
+ * <a 
href="https://docs.oracle.com/en/java/javase/25/docs/specs/man/javac.html#directory-hierarchies";>Directory
+ * hierarchies</a> are <i>package hierarchy</i>, <i>module hierarchy</i> and 
<i>module source hierarchy</i>, but
+ * for the purpose of the Maven Compiler Plugin we do not distinguish between 
the two latter.
+ *
+ * @see <a 
href="https://docs.oracle.com/en/java/javase/25/docs/specs/man/javac.html#directory-hierarchies";>Directory
 hierarchies</a>
+ */
+public enum DirectoryHierarchy {
+    /**
+     * Project using package hierarchy. This is the hierarchy used by all Java 
projects before Java 9.
+     * Note that it does not necessarily implies a class-path project. A 
modular project can still use
+     * the package hierarchy if the project contains only one module.
+     */
+    PACKAGE("versions"),
+
+    /**
+     * Project using package hierarchy, but in which a {@code module-info} 
file has been detected.
+     * This is used for compilation of tests. For the main code, we pretend 
that the hierarchy is
+     * {@link #MODULE_SOURCE} and move the directory output after compilation. 
Therefore, this
+     * enumeration value can be understood as "pseudo module source hierarchy".
+     *
+     * @see ModuleDirectoryRemover
+     *
+     * @deprecated Used only for compatibility with Maven 3.
+     */
+    @Deprecated
+    PACKAGE_WITH_MODULE("versions"),
+
+    /**
+     * A multi-module project using module source hierarchy. It could also be 
a module hierarchy,
+     * as the Maven Compiler Plugin does not need to distinguish <i>module 
hierarchy</i> and
+     * <i>module source hierarchy</i>.
+     */
+    MODULE_SOURCE("versions-modular");
+
+    /**
+     * The {@value} directory.
+     */
+    static final String META_INF = "META-INF";
+
+    /**
+     * Name of the {@code META-INF/} sub-directory where multi-release outputs 
are stored.
+     */
+    private final String versionDirectory;
+
+    /**
+     * Creates a new enumeration value.
+     *
+     * @param versionDirectory name of the {@code META-INF/} sub-directory 
where multi-release outputs are stored
+     */
+    DirectoryHierarchy(String versionDirectory) {
+        this.versionDirectory = versionDirectory;
+    }
+
+    /**
+     * Returns the directory where to write the compiled class files for all 
Java releases.
+     * The standard path for {@link #PACKAGE} hierarchy is {@code 
META-INF/versions}.
+     * The caller shall add the version number to the returned path.
+     *
+     * @param outputDirectory usually the value of {@link 
SourceDirectory#outputDirectory}
+     * @return the directory for all versions
+     */
+    public Path outputDirectoryForReleases(Path outputDirectory) {
+        // TODO: use Path.resolve(String, String...) with Java 22.
+        return outputDirectory.resolve(META_INF).resolve(versionDirectory);
+    }
+
+    /**
+     * Returns the directory where to write the compiled class files for a 
specific Java release.
+     * The standard path is {@code META-INF/versions/${release}} where {@code 
${release}} is the
+     * numerical value of the {@code release} argument. However for {@link 
#MODULE_SOURCE} case,
+     * the returned path is rather {@code 
META-INF/versions-modular/${release}}.
+     * The latter is non-standard because there is no standard multi-module 
<abbr>JAR</abbr> formats as of 2025.
+     * The use of {@code "versions-modular"} is for allowing other plugins 
such as Maven <abbr>JAR</abbr> plugin
+     * to avoid confusion with the standard case.
+     *
+     * @param outputDirectory usually the value of {@link 
SourceDirectory#outputDirectory}
+     * @param release the release, or {@code null} for the default release
+     * @return the directory for the classes of the specified version
+     */
+    public Path outputDirectoryForReleases(Path outputDirectory, SourceVersion 
release) {
+        if (release == null) {
+            release = SourceVersion.latestSupported();
+        }
+        String version = release.name(); // TODO: replace by runtimeVersion() 
in Java 18.
+        version = version.substring(version.lastIndexOf('_') + 1);
+        return outputDirectoryForReleases(outputDirectory).resolve(version);
+    }
+
+    /**
+     * Returns a string representation for use in error message.
+     *
+     * @return human-readable string representation
+     */
+    @Override
+    public String toString() {
+        return name().replace('_', ' ').toLowerCase(Locale.US);
+    }
+}
diff --git 
a/src/main/java/org/apache/maven/plugin/compiler/ForkedToolSources.java 
b/src/main/java/org/apache/maven/plugin/compiler/ForkedToolSources.java
index fac6b5d..b274252 100644
--- a/src/main/java/org/apache/maven/plugin/compiler/ForkedToolSources.java
+++ b/src/main/java/org/apache/maven/plugin/compiler/ForkedToolSources.java
@@ -64,6 +64,16 @@ final class ForkedToolSources implements 
StandardJavaFileManager {
      * {@link JavaPathType} because they are not about dependencies.
      */
     private record OtherPathType(String name, String optionString, String 
moduleName) implements PathType {
+        /**
+         * An option for the directory of source files of a module.
+         *
+         * @param   moduleName  name of the module
+         * @return  option for the directory of source files of the specified 
module
+         */
+        static OtherPathType moduleSources(String moduleName) {
+            return new OtherPathType("MODULE_SOURCE_PATH", 
"--module-source-path", moduleName);
+        }
+
         /**
          * The option for the directory of source files.
          */
@@ -417,7 +427,7 @@ final class ForkedToolSources implements 
StandardJavaFileManager {
                 throw new IllegalArgumentException("Unsupported location: " + 
location);
             }
         }
-        if (paths == null || paths.isEmpty()) {
+        if (isAbsent(paths)) {
             locations.remove(type);
         } else {
             locations.put(type, paths);
@@ -435,17 +445,24 @@ final class ForkedToolSources implements 
StandardJavaFileManager {
         if (location == StandardLocation.PATCH_MODULE_PATH) {
             type = JavaPathType.patchModule(moduleName);
         } else if (location == StandardLocation.MODULE_SOURCE_PATH) {
-            type = new OtherPathType("MODULE_SOURCE_PATH", 
"--module-source-path", moduleName);
+            type = OtherPathType.moduleSources(moduleName);
         } else {
             throw new IllegalArgumentException("Unsupported location: " + 
location);
         }
-        if (paths == null || paths.isEmpty()) {
+        if (isAbsent(paths)) {
             locations.remove(type);
         } else {
             locations.put(type, paths);
         }
     }
 
+    /**
+     * Returns whether the given collection is null or empty.
+     */
+    private static boolean isAbsent(Collection<?> c) {
+        return (c == null) || c.isEmpty();
+    }
+
     /**
      * Returns the search path associated with the given location, or {@code 
null} if none.
      */
diff --git 
a/src/main/java/org/apache/maven/plugin/compiler/IncrementalBuild.java 
b/src/main/java/org/apache/maven/plugin/compiler/IncrementalBuild.java
index a1f60a4..f2e1624 100644
--- a/src/main/java/org/apache/maven/plugin/compiler/IncrementalBuild.java
+++ b/src/main/java/org/apache/maven/plugin/compiler/IncrementalBuild.java
@@ -566,7 +566,7 @@ final class IncrementalBuild {
         }
         boolean rebuild = false;
         boolean allChanged = true;
-        List<Path> added = new ArrayList<>();
+        final var added = new ArrayList<Path>();
         for (SourceFile source : sourceFiles) {
             SourceInfo previous = previousBuild.remove(source.file);
             if (previous != null) {
@@ -646,13 +646,14 @@ final class IncrementalBuild {
      *
      * @see Aspect#DEPENDENCIES
      */
-    String dependencyChanges(Iterable<List<Path>> dependencies, 
Collection<String> fileExtensions) throws IOException {
+    String dependencyChanges(Iterable<Collection<Path>> dependencies, 
Collection<String> fileExtensions)
+            throws IOException {
         if (!cacheLoaded) {
             loadCache();
         }
         final FileTime changeTime = FileTime.fromMillis(previousBuildTime);
         final var updated = new ArrayList<Path>();
-        for (List<Path> roots : dependencies) {
+        for (Collection<Path> roots : dependencies) {
             for (Path root : roots) {
                 try (Stream<Path> files = Files.walk(root)) {
                     files.filter((f) -> {
diff --git 
a/src/main/java/org/apache/maven/plugin/compiler/ModuleDirectoryRemover.java 
b/src/main/java/org/apache/maven/plugin/compiler/ModuleDirectoryRemover.java
index 878b603..e653cce 100644
--- a/src/main/java/org/apache/maven/plugin/compiler/ModuleDirectoryRemover.java
+++ b/src/main/java/org/apache/maven/plugin/compiler/ModuleDirectoryRemover.java
@@ -31,6 +31,9 @@ import java.nio.file.Path;
  *
  * <p>The code in this class is useful only when {@link 
AbstractCompilerMojo#SUPPORT_LEGACY} is true.
  * This class can be fully deleted if a future version permanently set 
above-cited flag to false.</p>
+ *
+ * @see CompilerMojo#directoryLevelToRemove
+ * @see ToolExecutorForTest#directoryLevelToRemove
  */
 final class ModuleDirectoryRemover implements Closeable {
     /**
diff --git 
a/src/main/java/org/apache/maven/plugin/compiler/SourceDirectory.java 
b/src/main/java/org/apache/maven/plugin/compiler/SourceDirectory.java
index bb4ee92..676c935 100644
--- a/src/main/java/org/apache/maven/plugin/compiler/SourceDirectory.java
+++ b/src/main/java/org/apache/maven/plugin/compiler/SourceDirectory.java
@@ -206,47 +206,11 @@ final class SourceDirectory {
                 release = SourceVersion.latestSupported();
                 // `this.release` intentionally left to null.
             }
-            outputDirectory = outputDirectoryForReleases(moduleName != null, 
outputDirectory, release);
+            var hierarchy = (moduleName != null) ? 
DirectoryHierarchy.MODULE_SOURCE : DirectoryHierarchy.PACKAGE;
+            outputDirectory = 
hierarchy.outputDirectoryForReleases(outputDirectory, release);
         }
     }
 
-    /**
-     * Returns the directory where to write the compiled class files for a 
specific Java release.
-     * The standard path is {@code META-INF/versions/${release}} where {@code 
${release}} is the
-     * numerical value of the {@code release} argument. However if {@code 
modular} is {@code true},
-     * then the returned path is rather {@code 
META-INF/versions-modular/${release}}. The latter is
-     * non-standard because there is no standard multi-module <abbr>JAR</abbr> 
formats as of 2025.
-     * The use of {@code "versions-modular"} is for allowing other plugins 
such as Maven JAR plugin
-     * to avoid confusion with the standard case.
-     *
-     * @param modular whether each version directory contains module names
-     * @param outputDirectory usually the value of {@link #outputDirectory}
-     * @param release the release, or {@code null} for the default release
-     * @return the directory for the classes of the specified version
-     */
-    static Path outputDirectoryForReleases(boolean modular, Path 
outputDirectory, SourceVersion release) {
-        if (release == null) {
-            release = SourceVersion.latestSupported();
-        }
-        String version = release.name(); // TODO: replace by runtimeVersion() 
in Java 18.
-        version = version.substring(version.lastIndexOf('_') + 1);
-        return outputDirectoryForReleases(modular, 
outputDirectory).resolve(version);
-    }
-
-    /**
-     * Returns the directory where to write the compiled class files for all 
Java releases.
-     * The standard path (when {@code modular} is {@code false}) is {@code 
META-INF/versions}.
-     * The caller shall add the version number to the returned path.
-     *
-     * @param modular whether each version directory contains module names
-     * @param outputDirectory usually the value of {@link #outputDirectory}
-     * @return the directory for all versions
-     */
-    static Path outputDirectoryForReleases(boolean modular, Path 
outputDirectory) {
-        // TODO: use Path.resolve(String, String...) with Java 22.
-        return outputDirectory.resolve("META-INF").resolve(modular ? 
"versions-modular" : "versions");
-    }
-
     /**
      * {@return the target version as an object from the Java tools API}
      *
@@ -266,7 +230,7 @@ final class SourceDirectory {
      * @return the parsed version, or {@code null} if the given string was 
null or empty
      * @throws UnsupportedVersionException if the version string cannot be 
parsed
      */
-    private static SourceVersion parse(final String version) {
+    static SourceVersion parse(final String version) {
         if (version == null || version.isBlank()) {
             return null;
         }
diff --git 
a/src/main/java/org/apache/maven/plugin/compiler/SourcesForRelease.java 
b/src/main/java/org/apache/maven/plugin/compiler/SourcesForRelease.java
index 8c11390..5180f7f 100644
--- a/src/main/java/org/apache/maven/plugin/compiler/SourcesForRelease.java
+++ b/src/main/java/org/apache/maven/plugin/compiler/SourcesForRelease.java
@@ -22,9 +22,9 @@ import javax.lang.model.SourceVersion;
 
 import java.io.Closeable;
 import java.io.IOException;
-import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
@@ -80,7 +80,7 @@ final class SourcesForRelease implements Closeable {
      * Snapshot of {@link ToolExecutor#dependencies}.
      * This information is saved in case a {@code target/javac.args} debug 
file needs to be written.
      */
-    Map<PathType, List<Path>> dependencySnapshot;
+    Map<PathType, Collection<Path>> dependencySnapshot;
 
     /**
      * The output directory for the release. This is either the base output 
directory or a sub-directory
@@ -134,36 +134,6 @@ final class SourcesForRelease implements Closeable {
         files.add(source.file);
     }
 
-    /**
-     * If there is any {@code module-info.class} in the main classes that are 
overwritten by this set of sources,
-     * temporarily replace the main files by the test files. The {@link 
#close()} method must be invoked after
-     * this method for resetting the original state.
-     *
-     * <p>This method is invoked when the test files overwrite the {@code 
module-info.class} from the main files.
-     * This method should not be invoked during the compilation of main 
classes, as its behavior may be not well
-     * defined.</p>
-     */
-    void substituteModuleInfos(final Path mainOutputDirectory, final Path 
testOutputDirectory) throws IOException {
-        for (Map.Entry<SourceDirectory, ModuleInfoOverwrite> entry : 
moduleInfos.entrySet()) {
-            Path main = mainOutputDirectory;
-            Path test = testOutputDirectory;
-            SourceDirectory directory = entry.getKey();
-            String moduleName = directory.moduleName;
-            if (moduleName != null) {
-                main = main.resolve(moduleName);
-                if (!Files.isDirectory(main)) {
-                    main = mainOutputDirectory;
-                }
-                test = test.resolve(moduleName);
-                if (!Files.isDirectory(test)) {
-                    test = testOutputDirectory;
-                }
-            }
-            Path source = directory.getModuleInfo().orElseThrow(); // Should 
never be absent for entries in the map.
-            entry.setValue(ModuleInfoOverwrite.create(source, main, test));
-        }
-    }
-
     /**
      * Restores the hidden {@code module-info.class} files to their original 
names.
      */
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 1fa3649..1cdc336 100644
--- a/src/main/java/org/apache/maven/plugin/compiler/TestCompilerMojo.java
+++ b/src/main/java/org/apache/maven/plugin/compiler/TestCompilerMojo.java
@@ -212,13 +212,6 @@ public class TestCompilerMojo extends AbstractCompilerMojo 
{
      */
     private transient boolean hasMainModuleInfo;
 
-    /**
-     * Path to the {@code module-info.class} file of the main code, or {@code 
null} if that file does not exist.
-     * This field exists only for transferring this information to {@link 
ToolExecutorForTest#mainModulePath},
-     * and should be {@code null} the rest of the time.
-     */
-    transient Path mainModulePath;
-
     /**
      * The file where to dump the command-line when debug is activated or when 
the compilation failed.
      * For example, if the value is {@code "javac-test"}, then the Java 
compiler can be launched
@@ -368,19 +361,22 @@ public class TestCompilerMojo extends 
AbstractCompilerMojo {
     }
 
     /**
-     * {@return the module name declared in the test sources}
+     * {@return the module name found in the package hierarchy of given 
sources}
      * We have to parse the source instead of the {@code module-info.class} 
file
-     * because the classes may not have been compiled yet.
-     * This is not very reliable, but putting a {@code module-info.java} file 
in the tests is deprecated anyway.
+     * because the classes may not have been compiled yet. This is not 
reliable,
+     * but the use of package hierarchy for modular project should be avoided 
in
+     * Maven 4.
+     *
+     * @deprecated Declare modules in {@code <source>} elements instead.
      */
-    final String getTestModuleName(List<SourceDirectory> compileSourceRoots) 
throws IOException {
+    @Deprecated(since = "4.0.0")
+    final String moduleNameFromPackageHierarchy(List<SourceDirectory> 
compileSourceRoots) throws IOException {
         for (SourceDirectory directory : compileSourceRoots) {
-            if (directory.moduleName != null) {
-                return directory.moduleName;
-            }
-            String name = 
parseModuleInfoName(directory.getModuleInfo().orElse(null));
-            if (name != null) {
-                return name;
+            if (directory.moduleName == null) {
+                String name = 
parseModuleInfoName(directory.getModuleInfo().orElse(null));
+                if (name != null) {
+                    return name;
+                }
             }
         }
         return null;
@@ -388,8 +384,11 @@ public class TestCompilerMojo extends AbstractCompilerMojo 
{
 
     /**
      * {@return whether the project has at least one {@code module-info.class} 
file}
+     * The {@code module-info.class} should be located in the main source code.
+     * However, this method checks also in the test source code for 
compatibility with Maven 3,
+     * but this practice is deprecated.
      *
-     * @param roots root directories of the sources to compile
+     * @param roots root directories of the source files of the test classes 
to compile
      * @throws IOException if this method needed to read a module descriptor 
and failed
      */
     @Override
@@ -426,18 +425,12 @@ public class TestCompilerMojo extends 
AbstractCompilerMojo {
      */
     @Override
     public ToolExecutor createExecutor(DiagnosticListener<? super 
JavaFileObject> listener) throws IOException {
-        try {
-            Path file = mainOutputDirectory.resolve(MODULE_INFO + 
CLASS_FILE_SUFFIX);
-            if (Files.isRegularFile(file)) {
-                mainModulePath = file;
-                hasMainModuleInfo = true;
-            }
-            return new ToolExecutorForTest(this, listener);
-        } finally {
-            // Reset the fields that were used only for transfering 
information to `ToolExecutorForTest`.
-            hasTestModuleInfo = false;
-            hasMainModuleInfo = false;
+        Path mainModulePath = mainOutputDirectory.resolve(MODULE_INFO + 
CLASS_FILE_SUFFIX);
+        if (Files.isRegularFile(mainModulePath)) {
+            hasMainModuleInfo = true;
+        } else {
             mainModulePath = null;
         }
+        return new ToolExecutorForTest(this, listener, mainModulePath);
     }
 }
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 1a9b1f2..907d323 100644
--- a/src/main/java/org/apache/maven/plugin/compiler/ToolExecutor.java
+++ b/src/main/java/org/apache/maven/plugin/compiler/ToolExecutor.java
@@ -33,10 +33,13 @@ import java.nio.charset.Charset;
 import java.nio.file.DirectoryNotEmptyException;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.util.ArrayDeque;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.Deque;
 import java.util.EnumMap;
 import java.util.EnumSet;
+import java.util.HashMap;
 import java.util.LinkedHashMap;
 import java.util.LinkedHashSet;
 import java.util.List;
@@ -116,6 +119,16 @@ public class ToolExecutor {
      */
     protected final boolean hasModuleDeclaration;
 
+    /**
+     * How the source code of the project is organized, or {@code null} if not 
yet determined.
+     * <a 
href="https://docs.oracle.com/en/java/javase/25/docs/specs/man/javac.html#directory-hierarchies";>Directory
+     * hierarchies</a> are <i>package hierarchy</i>, <i>module hierarchy</i> 
and <i>module source hierarchy</i>, but
+     * for the purpose of the compiler plugin we do not distinguish between 
the two latter.
+     *
+     * @see #determineDirectoryHierarchy(Collection)
+     */
+    private DirectoryHierarchy directoryHierarchy;
+
     /**
      * The result of resolving the dependencies, or {@code null} if not 
available or not needed.
      * For example, this field may be null if the constructor found no file to 
compile,
@@ -137,7 +150,7 @@ public class ToolExecutor {
      * @see #dependencies(PathType)
      * @see #prependDependency(PathType, Path)
      */
-    protected final Map<PathType, List<Path>> dependencies;
+    private final Map<PathType, Collection<Path>> dependencies;
 
     /**
      * The destination directory (or class output directory) for class files.
@@ -263,11 +276,11 @@ public class ToolExecutor {
         dependencyResolution = mojo.resolveDependencies(hasModuleDeclaration);
         if (dependencyResolution != null) {
             dependencies.putAll(dependencyResolution.getDispatchedPaths());
-            copyDependencyValues();
         }
         mojo.resolveProcessorPathEntries(dependencies);
         mojo.amendincrementalCompilation(incrementalBuildConfig, 
dependencies.keySet());
         generatedSourceDirectories = 
mojo.addGeneratedSourceDirectory(dependencies.keySet());
+        copyDependencyValues();
     }
 
     /**
@@ -278,6 +291,50 @@ public class ToolExecutor {
         dependencies.entrySet().forEach((entry) -> 
entry.setValue(List.copyOf(entry.getValue())));
     }
 
+    /**
+     * Returns the output directory of the main classes if they were compiled 
in a previous Maven phase.
+     * This method shall always return {@code null} when compiling to main 
code. The return value can be
+     * non-null only when compiling the test classes, in which case the 
returned path is the directory to
+     * prepend to the class-path or module-path before to compile the classes 
managed by this executor.
+     *
+     * @return the directory to prepend to the class-path or module-path, or 
{@code null} if none
+     */
+    Path getOutputDirectoryOfPreviousPhase() {
+        return null;
+    }
+
+    /**
+     * Returns the directory of the classes compiled for the specified module.
+     * If the project is multi-release, this method returns the directory for 
the base version.
+     *
+     * <p>This is normally a sub-directory of the same name as the module name.
+     * However, when building tests for a project which is both multi-release 
and multi-module,
+     * the directory may exist only for a target Java version higher than the 
base version.</p>
+     *
+     * @param outputDirectory the output directory which is the root of modules
+     * @param moduleName the name of the module for which the class directory 
is desired
+     * @return directories of classes for the given module
+     */
+    Path resolveModuleOutputDirectory(Path outputDirectory, String moduleName) 
{
+        return outputDirectory.resolve(moduleName);
+    }
+
+    /**
+     * Name of the module when using package hierarchy, or {@code null} if not 
applicable.
+     * This is used for setting {@code --patch-module} option during 
compilation of tests.
+     * This field is null in a class-path project or in a multi-module project.
+     *
+     * <p>This information is used for compatibility with the Maven 3 way to 
build a modular project.
+     * It is recommended to use the {@code <sources>} element instead. We may 
remove this method in a
+     * future version if we abandon compatibility with the Maven 3 way to 
build modular projects.</p>
+     *
+     * @deprecated Declare modules in {@code <source>} elements instead.
+     */
+    @Deprecated(since = "4.0.0")
+    String moduleNameFromPackageHierarchy() {
+        return null;
+    }
+
     /**
      * {@return whether a release version is specified for all sources}
      */
@@ -361,20 +418,33 @@ public class ToolExecutor {
     }
 
     /**
-     * {@return a modifiable list of paths to all dependencies of the given 
type}
-     * The returned list is intentionally live: elements can be added or 
removed
-     * from the list for changing the state of this executor.
+     * Writes the incremental build cache into the {@code 
target/maven-status/maven-compiler-plugin/} directory.
+     * This method should be invoked only once. Next invocations after the 
first one have no effect.
+     *
+     * @throws IOException if an error occurred while writing the cache
+     */
+    private void saveIncrementalBuild() throws IOException {
+        if (incrementalBuild != null) {
+            incrementalBuild.writeCache();
+            incrementalBuild = null;
+        }
+    }
+
+    /**
+     * {@return a modifiable collection of paths to all dependencies of the 
given type}
+     * The returned collection is intentionally live: elements can be added or 
removed
+     * from the collection for changing the state of this executor.
      *
      * @param  pathType  type of path for which to get the dependencies
      */
-    protected List<Path> dependencies(PathType pathType) {
-        return dependencies.compute(pathType, (key, paths) -> {
+    protected Deque<Path> dependencies(PathType pathType) {
+        return (Deque<Path>) dependencies.compute(pathType, (key, paths) -> {
             if (paths == null) {
-                return new ArrayList<>();
-            } else if (paths instanceof ArrayList<?>) {
-                return paths;
+                return new ArrayDeque<>();
+            } else if (paths instanceof ArrayDeque<Path> deque) {
+                return deque;
             } else {
-                var copy = new ArrayList<Path>(paths.size() + 4); // 
Anticipate the addition of new elements.
+                var copy = new ArrayDeque<Path>(paths.size() + 4); // 
Anticipate the addition of new elements.
                 copy.addAll(paths);
                 return copy;
             }
@@ -389,8 +459,8 @@ public class ToolExecutor {
      */
     private void setDependencyPaths(final StandardJavaFileManager fileManager) 
throws IOException {
         final var unresolvedPaths = new ArrayList<Path>();
-        for (Map.Entry<PathType, List<Path>> entry : dependencies.entrySet()) {
-            List<Path> paths = entry.getValue();
+        for (Map.Entry<PathType, Collection<Path>> entry : 
dependencies.entrySet()) {
+            Collection<Path> paths = entry.getValue();
             PathType key = entry.getKey();
             if (key instanceof JavaPathType type) {
                 /*
@@ -411,7 +481,7 @@ public class ToolExecutor {
                              * specify the class output directory as one of 
the locations on the user class path,
                              * using the --class-path option or one of its 
alternate forms."
                              */
-                            paths = new ArrayList<>(paths);
+                            paths = new ArrayDeque<>(paths);
                             paths.add(outputDirectory);
                             entry.setValue(paths);
                         }
@@ -453,9 +523,9 @@ public class ToolExecutor {
      * @param  first the path to put first
      * @return the new paths for the given type, as a modifiable list
      */
-    protected List<Path> prependDependency(final PathType pathType, final Path 
first) {
-        List<Path> paths = dependencies(pathType);
-        paths.add(0, first);
+    protected Deque<Path> prependDependency(final PathType pathType, final 
Path first) {
+        Deque<Path> paths = dependencies(pathType);
+        paths.addFirst(first);
         return paths;
     }
 
@@ -466,19 +536,6 @@ public class ToolExecutor {
         return (release != null) ? release : SourceVersion.latest();
     }
 
-    /**
-     * If the given module name is empty, tries to infer a default module 
name. A module name is inferred
-     * (tentatively) when the <abbr>POM</abbr> file does not contain an 
explicit {@code <module>} element.
-     * This method exists only for compatibility with the Maven 3 way to do a 
modular project.
-     *
-     * @param moduleName the module name, or an empty string if not explicitly 
specified
-     * @return the specified module name, or an inferred module name if 
available, or an empty string
-     * @throws IOException if the module descriptor cannot be read.
-     */
-    String inferModuleNameIfMissing(String moduleName) throws IOException {
-        return moduleName;
-    }
-
     /**
      * Groups all sources files first by Java release versions, then by module 
names.
      * The elements are sorted in the order of {@link SourceVersion} 
enumeration values,
@@ -511,44 +568,18 @@ public class ToolExecutor {
     }
 
     /**
-     * Creates the file manager which will be used by the compiler.
-     * This method does not configure the locations (sources, dependencies, 
<i>etc.</i>).
-     * Locations will be set by {@link #compile(JavaCompiler, Options, 
Writer)} on the
-     * file manager returned by this method.
+     * Checks if there are no sources to compile and handles that case.
+     * When there are no sources, this method cleans up the output directory 
and logs a message.
      *
-     * @param compiler the compiler
-     * @param workaround whether to apply {@link WorkaroundForPatchModule}
-     * @return the file manager to use
+     * @return {@code true} if there are no sources to compile, {@code false} 
if there are sources
+     * @throws IOException if an error occurred while deleting the empty 
output directory
      */
-    private StandardJavaFileManager createFileManager(JavaCompiler compiler, 
boolean workaround) {
-        StandardJavaFileManager fileManager = 
compiler.getStandardFileManager(listener, LOCALE, encoding);
-        if (WorkaroundForPatchModule.ENABLED && workaround && !(compiler 
instanceof ForkedTool)) {
-            fileManager = new WorkaroundForPatchModule(fileManager);
-        }
-        return fileManager;
-    }
-
-    /**
-     * Runs the compilation task.
-     *
-     * @param compiler the compiler
-     * @param configuration the options to give to the Java compiler
-     * @param otherOutput where to write additional output from the compiler
-     * @return whether the compilation succeeded
-     * @throws IOException if an error occurred while reading or writing a file
-     * @throws MojoException if the compilation failed for a reason identified 
by this method
-     * @throws RuntimeException if any other kind of  error occurred
-     */
-    @SuppressWarnings("checkstyle:MethodLength")
-    public boolean compile(final JavaCompiler compiler, final Options 
configuration, final Writer otherOutput)
-            throws IOException {
-        /*
-         * Announce what the compiler is about to do.
-         */
+    private boolean noSourcesToCompile() throws IOException {
         sourcesForDebugFile.clear();
         if (sourceFiles.isEmpty()) {
             String message = "No sources to compile.";
             try {
+                // The directory must exist since it was created in the 
constructor.
                 Files.delete(outputDirectory);
             } catch (DirectoryNotEmptyException e) {
                 message += " However, the output directory is not empty.";
@@ -565,154 +596,393 @@ public class ToolExecutor {
             }
             logger.debug(sb);
         }
-        /*
-         * Create a `JavaFileManager`, configure all paths (dependencies and 
sources), then run the compiler.
-         * The Java file manager has a cache, so it needs to be disposed after 
the compilation is completed.
-         * The same `JavaFileManager` may be reused for many compilation units 
(e.g. multi-release) before
-         * disposal in order to reuse its cache.
-         */
-        boolean success = true;
-        try (StandardJavaFileManager fileManager = createFileManager(compiler, 
hasModuleDeclaration)) {
-            setDependencyPaths(fileManager);
-            if (!generatedSourceDirectories.isEmpty()) {
-                
fileManager.setLocationFromPaths(StandardLocation.SOURCE_OUTPUT, 
generatedSourceDirectories);
-            }
-            boolean isVersioned = false;
-            Path latestOutputDirectory = null;
-            /*
-             * More than one compilation unit may exist in the case of a 
multi-release project.
-             * Units are compiled in the order of the release version, with 
base compiled first.
-             * At the beginning of each new iteration, `latestOutputDirectory` 
is the path to
-             * the compiled classes of the previous version.
-             */
-            compile:
-            for (final SourcesForRelease unit : groupByReleaseAndModule()) {
-                Path outputForRelease = null;
-                boolean isClasspathProject = false;
-                boolean isModularProject = false;
-                String defaultModuleName = null;
-                configuration.setRelease(unit.getReleaseString());
-                for (final Map.Entry<String, Set<Path>> root : 
unit.roots.entrySet()) {
-                    final String declaredModuleName = root.getKey();
-                    final String moduleName = 
inferModuleNameIfMissing(declaredModuleName);
-                    if (moduleName.isEmpty()) {
-                        isClasspathProject = true;
+        return false;
+    }
+
+    /**
+     * Determines the directory hierarchy by scanning all compilation units.
+     * Also validates that there are no conflicting directory hierarchies
+     * and performs the necessary remapping for Maven 3 compatibility.
+     * This should be called once before processing any units.
+     *
+     * @param units all compilation units to scan
+     * @throws CompilationFailureException if both explicit and detected 
module names are present
+     */
+    private void determineDirectoryHierarchy(final 
Collection<SourcesForRelease> units) {
+        final String moduleNameFromPackageHierarchy = 
moduleNameFromPackageHierarchy();
+        for (SourcesForRelease unit : units) {
+            for (String moduleName : unit.roots.keySet()) {
+                DirectoryHierarchy detected;
+                if (moduleName.isEmpty()) {
+                    if (moduleNameFromPackageHierarchy == null) {
+                        detected = DirectoryHierarchy.PACKAGE;
                     } else {
-                        isModularProject = true;
-                        if (declaredModuleName.isEmpty()) { // Modular project 
using package source hierarchy.
-                            defaultModuleName = moduleName;
-                        }
-                    }
-                    if (isClasspathProject & isModularProject) {
-                        throw new CompilationFailureException("Mix of modular 
and non-modular sources.");
+                        detected = DirectoryHierarchy.PACKAGE_WITH_MODULE;
                     }
-                    final Set<Path> sourcePaths = root.getValue();
-                    if (isClasspathProject) {
-                        
fileManager.setLocationFromPaths(StandardLocation.SOURCE_PATH, sourcePaths);
+                } else {
+                    if (moduleNameFromPackageHierarchy == null) {
+                        detected = DirectoryHierarchy.MODULE_SOURCE;
                     } else {
-                        
fileManager.setLocationForModule(StandardLocation.MODULE_SOURCE_PATH, 
moduleName, sourcePaths);
-                    }
-                    outputForRelease = outputDirectory; // Modified below if 
compiling a non-base release.
-                    if (isVersioned) {
-                        outputForRelease = 
Files.createDirectories(SourceDirectory.outputDirectoryForReleases(
-                                isModularProject, outputForRelease, 
unit.release));
-                        if (isClasspathProject) {
-                            /*
-                             * For a non-modular project, this block is 
executed at most once par compilation unit.
-                             * Add the paths to the classes compiled for 
previous versions.
-                             */
-                            List<Path> classpath = 
prependDependency(JavaPathType.CLASSES, latestOutputDirectory);
-                            
fileManager.setLocationFromPaths(StandardLocation.CLASS_PATH, classpath);
-                        } else {
-                            /*
-                             * For a modular project, this block can be 
executed an arbitrary number of times
-                             * (once per module).
-                             */
-                            Path latestOutputForModule = 
latestOutputDirectory.resolve(moduleName);
-                            JavaPathType.Modular pathType = 
JavaPathType.patchModule(moduleName);
-                            List<Path> paths = prependDependency(pathType, 
latestOutputForModule);
-                            
fileManager.setLocationForModule(StandardLocation.PATCH_MODULE_PATH, 
moduleName, paths);
-                        }
+                        // Mix of package hierarchy and module source 
hierarchy.
+                        throw new CompilationFailureException(
+                                "The \"%s\" module must be declared in a 
<module> element of <sources>."
+                                        
.formatted(moduleNameFromPackageHierarchy));
                     }
                 }
-                /*
-                 * At this point, we finished to set the source paths. We have 
also modified the class-path or
-                 * patched the modules with the output directories of codes 
compiled for lower Java releases.
-                 * The `defaultModuleName` is an adjustment done when the 
project is a Java module, but still
-                 * organized in a package source hierarchy instead of a module 
source hierarchy. Updating the
-                 * `unit.roots` map is not needed for this class, but done in 
case a `target/javac.args` file
-                 * will be written after the compilation.
-                 */
-                if (defaultModuleName != null) {
-                    Set<Path> paths = unit.roots.remove("");
-                    if (paths != null) {
-                        unit.roots.put(defaultModuleName, paths);
-                    }
+                if (directoryHierarchy == null) {
+                    directoryHierarchy = detected;
+                } else if (directoryHierarchy != detected) {
+                    throw new CompilationFailureException(
+                            "Mix of %s and %s 
hierarchies.".formatted(directoryHierarchy, detected));
                 }
-                copyDependencyValues();
-                unit.dependencySnapshot = new LinkedHashMap<>(dependencies);
-                
fileManager.setLocationFromPaths(StandardLocation.CLASS_OUTPUT, 
Set.of(outputForRelease));
-                latestOutputDirectory = outputForRelease;
-                unit.outputForRelease = outputForRelease;
-                sourcesForDebugFile.add(unit);
+            }
+        }
+        /*
+         * The following adjustment is for the case when the project is a Java 
module, but nevertheless organized
+         * in a package hierarchy instead of a module source hierarchy. Update 
the `unit.roots` map for compiling
+         * the module as if module source hiearchy was used. It will require 
moving the output directory after
+         * compilation, which is done by `ModuleDirectoryRemover`.
+         */
+        if (moduleNameFromPackageHierarchy != null) {
+            for (SourcesForRelease unit : units) {
+                Set<Path> paths = unit.roots.remove("");
+                if (paths != null) {
+                    unit.roots.put(moduleNameFromPackageHierarchy, paths);
+                }
+            }
+        }
+    }
+
+    /**
+     * Manager of class-path or module-paths specified to a {@link 
StandardJavaFileManager}.
+     * This base class assumes {@link DirectoryHierarchy#PACKAGE}, and a 
subclass is defined
+     * for the {@link DirectoryHierarchy#MODULE_SOURCE} case.
+     */
+    private class PathManager {
+        /**
+         * The file manager to configure for class-path or module-paths.
+         */
+        protected final StandardJavaFileManager fileManager;
+
+        /**
+         * The output directory of the previous compilation phase or version.
+         * For test compilation, this is the main output directory.
+         * For multi-release, this is the output of the previous Java version.
+         */
+        protected Path latestOutputDirectory;
+
+        /**
+         * Whether we are compiling a version after the base version.
+         *
+         * @see #markVersioned()
+         */
+        private boolean isVersioned;
+
+        /**
+         * Creates a new path manager for the given file manager.
+         *
+         * @param fileManager the file manager to configure for class-path or 
module-paths
+         */
+        protected PathManager(StandardJavaFileManager fileManager) {
+            this.fileManager = fileManager;
+            latestOutputDirectory = getOutputDirectoryOfPreviousPhase();
+        }
+
+        /**
+         * Merges all the given sets into a single set. We use our own loop 
instead of streams
+         * because the given collection should always contain exactly one 
{@code Set<Path>},
+         * so we can return that set directly without copying its content in a 
new set.
+         * The merge is a paranoiac safety as we could also throw an exception 
instead.
+         */
+        private static Set<Path> merge(final Collection<Set<Path>> 
directories) {
+            Set<Path> allSources = Set.of();
+            for (Set<Path> more : directories) {
+                if (allSources.isEmpty()) {
+                    allSources = more;
+                } else {
+                    // Should never happen, but merge anyway by safety.
+                    allSources = new LinkedHashSet<>(allSources);
+                    allSources.addAll(more);
+                }
+            }
+            return allSources;
+        }
+
+        /**
+         * Configures source directories for all roots in a compilation unit.
+         * Also configures the class-path or module-paths with the output 
directories
+         * of previous compilation units (if any).
+         *
+         * <h4>Default implementation</h4>
+         * The default implementation configures source directories and 
class-path for package hierarchy
+         * without {@code module-info}. Sub-classes need to override this 
method if the project is modular.
+         *
+         * @param roots map of module names to source paths
+         * @throws IOException if an error occurred while setting locations
+         */
+        protected void configureSourcePaths(final Map<String, Set<Path>> 
roots) throws IOException {
+            fileManager.setLocationFromPaths(StandardLocation.SOURCE_PATH, 
merge(roots.values()));
+
+            // For multi-release builds, add previous version's output to 
class-path.
+            if (latestOutputDirectory != null) {
+                Deque<Path> paths = prependDependency(JavaPathType.CLASSES, 
latestOutputDirectory);
+                fileManager.setLocationFromPaths(StandardLocation.CLASS_PATH, 
paths);
+            }
+        }
+
+        /**
+         * Sets up the output directory for a compilation unit.
+         *
+         * @param unit the compilation unit
+         * @throws IOException if an error occurred while creating directories 
or setting locations
+         */
+        final void setupOutputDirectory(final SourcesForRelease unit) throws 
IOException {
+            Path outputForRelease = outputDirectory;
+            if (isVersioned) {
+                outputForRelease = Files.createDirectories(
+                        
directoryHierarchy.outputDirectoryForReleases(outputForRelease, unit.release));
+            }
+            fileManager.setLocationFromPaths(StandardLocation.CLASS_OUTPUT, 
Set.of(outputForRelease));
+            // Records that a compilation unit completed, updating the 
baseline for the next phase.
+            latestOutputDirectory = outputForRelease;
+            unit.outputForRelease = outputForRelease;
+            sourcesForDebugFile.add(unit);
+        }
+
+        /**
+         * Marks that subsequent iterations are for versions after the base 
version.
+         */
+        final void markVersioned() {
+            isVersioned = true;
+        }
+    }
+
+    /**
+     * Manager of module-paths specified to a {@link StandardJavaFileManager}.
+     * This subclass handles the {@link DirectoryHierarchy#MODULE_SOURCE} case.
+     *
+     * <h2>Implementation details</h2>
+     * The fields in this class are used for patching, i.e. when compiling 
test classes or a non-base version
+     * of a multi-release project. The output directory of the previous Java 
version needs to be added to the
+     * class-path or module-path. However, in the case of a modular project, 
we can add to the module path only
+     * once and all other additions must be done as patches.
+     */
+    private final class ModulePathManager extends PathManager {
+        /**
+         * Whether we can add output directories to the module-path.
+         * For modular projects, we can only add to module-path once.
+         * Subsequent additions must use {@code --patch-module}.
+         */
+        private boolean canAddOutputToModulePath;
+
+        /**
+         * Tracks modules from previous versions that may not be present in 
the current version.
+         * Keys are module names, values indicate whether cleanup is needed.
+         */
+        private final Map<String, Boolean> modulesNotPresentInNewVersion;
+
+        /**
+         * Tracks how many source directories were added as patches per module.
+         * Keys are module names, values are the count of source directories.
+         * Used to remove these source entries and replace them with compiled 
output.
+         *
+         * <h4>Purpose</h4>
+         * When patching a module, the source directories of the compilation 
unit are declared as a patch applied
+         * over the output directories of previous compilation units. But 
after the compilation, if there are more
+         * units to compile, we will need to replace the sources in {@code 
--patch-module} by the compilation output
+         * before to declare the source directories of the next compilation 
unit.
+         */
+        private final Map<String, Integer> modulesWithSourcesAsPatches;
+
+        /**
+         * Creates a new path manager for the given file manager.
+         *
+         * @param fileManager  the  file manager to configure for class-path 
or module-paths
+         */
+        ModulePathManager(StandardJavaFileManager fileManager) {
+            super(fileManager);
+            canAddOutputToModulePath = true;
+            modulesNotPresentInNewVersion = new LinkedHashMap<>();
+            modulesWithSourcesAsPatches = new HashMap<>();
+        }
+
+        /**
+         * Configures module source paths for all roots in a compilation unit.
+         * If the project uses package hierarchy with a {@code module-info} 
file,
+         * the module names in the keys of the {@code roots} map must have 
been resolved by
+         * {@link #determineDirectoryHierarchy(Collection)} before to invoke 
this method.</p>
+         *
+         * <p>Configures also the {@code --patch-module} options for a module 
being compiled for
+         * a newer Java version. The patch consists of (in order, highest 
priority first):</p>
+         * <ol>
+         *   <li>Current source paths (so the compiler sees the new version's 
sources).</li>
+         *   <li>Output from previous Java version (compiled classes to 
inherit).</li>
+         *   <li>Existing patch-module dependencies.</li>
+         * </ol>
+         *
+         * @param roots map of module names to source paths
+         * @throws IOException if an error occurred while setting locations
+         */
+        @Override
+        protected void configureSourcePaths(final Map<String, Set<Path>> 
roots) throws IOException {
+            for (var entry : roots.entrySet()) {
+                final String moduleName = entry.getKey();
+                final Set<Path> sourcePaths = entry.getValue();
+                
fileManager.setLocationForModule(StandardLocation.MODULE_SOURCE_PATH, 
moduleName, sourcePaths);
+                modulesNotPresentInNewVersion.put(moduleName, Boolean.FALSE);
                 /*
-                 * Compile the source files now. The following loop should be 
executed exactly once.
-                 * It may be executed twice when compiling test classes 
overwriting the `module-info`,
-                 * in which case the `module-info` needs to be compiled 
separately from other classes.
-                 * However, this is a deprecated practice.
+                 * When compiling for the base Java version, the configuration 
for current module is finished.
+                 * The remaining of this loop is executed only for target Java 
versions after the base version.
+                 * In those cases, we need to add the paths to the classes 
compiled for the previous version.
+                 * A non-modular project would always add the paths to the 
class-path. For a modular project,
+                 * add the paths to the module-path only the first time. 
After, we need to use patch-module.
                  */
-                JavaCompiler.CompilationTask task;
-                for (CompilationTaskSources c : toCompilationTasks(unit)) {
-                    Iterable<? extends JavaFileObject> sources = 
fileManager.getJavaFileObjectsFromPaths(c.files);
-                    StandardJavaFileManager workaround = fileManager;
-                    boolean workaroundNeedsClose = false;
-                    // Check flag separately to clearly indicate this entire 
block is a workaround hack.
-                    if (WorkaroundForPatchModule.ENABLED) {
-                        if (workaround instanceof WorkaroundForPatchModule wp) 
{
-                            workaround = wp.getFileManagerIfUsable();
-                            if (workaround == null) {
-                                workaround = createFileManager(compiler, 
false);
-                                wp.copyTo(workaround);
-                                workaroundNeedsClose = true;
-                            }
-                        }
-                    }
-                    task = compiler.getTask(otherOutput, workaround, listener, 
configuration.options, null, sources);
-                    success = c.compile(task);
-                    if (workaroundNeedsClose) {
-                        workaround.close();
+                if (latestOutputDirectory != null) {
+                    if (canAddOutputToModulePath) {
+                        canAddOutputToModulePath = false;
+                        Deque<Path> paths = 
prependDependency(JavaPathType.MODULES, latestOutputDirectory);
+                        
fileManager.setLocationFromPaths(StandardLocation.MODULE_PATH, paths);
                     }
-                    if (!success) {
-                        break compile;
+                    /*
+                     * For a modular project, following block can be executed 
an arbitrary number of times
+                     * We need to declare that the sources that we are 
compiling are for patching a module.
+                     * But we also need to remember that these sources will 
need to be removed in the next
+                     * iteration, because they will be replaced by the 
compiled classes (the above block).
+                     */
+                    final Deque<Path> paths = 
dependencies(JavaPathType.patchModule(moduleName));
+                    removeFirsts(paths, 
modulesWithSourcesAsPatches.put(moduleName, sourcePaths.size()));
+                    Path latestOutput = 
resolveModuleOutputDirectory(latestOutputDirectory, moduleName);
+                    if (Files.exists(latestOutput)) {
+                        paths.addFirst(latestOutput);
                     }
+                    sourcePaths.forEach(paths::addFirst);
+                    
fileManager.setLocationForModule(StandardLocation.PATCH_MODULE_PATH, 
moduleName, paths);
                 }
-                isVersioned = true; // Any further iteration is for a version 
after the base version.
             }
-            /*
-             * Post-compilation.
-             */
-            if (listener instanceof DiagnosticLogger diagnostic) {
-                diagnostic.logSummary();
+            omitSourcelessModulesInNewVersion();
+        }
+
+        /**
+         * Removes from compilation the modules that were present in previous 
version but not in the current version.
+         * This clears the source paths and updates patch-module for leftover 
modules.
+         * This method has no effect when compiling for the base Java version.
+         *
+         * @throws IOException if an error occurred while setting locations
+         */
+        private void omitSourcelessModulesInNewVersion() throws IOException {
+            for (var iterator = 
modulesNotPresentInNewVersion.entrySet().iterator(); iterator.hasNext(); ) {
+                Map.Entry<String, Boolean> entry = iterator.next();
+                if (entry.getValue()) {
+                    String moduleName = entry.getKey();
+                    Deque<Path> paths = 
dependencies(JavaPathType.patchModule(moduleName));
+                    if (removeFirsts(paths, 
modulesWithSourcesAsPatches.remove(moduleName))) {
+                        
paths.addFirst(latestOutputDirectory.resolve(moduleName));
+                    } else if (paths.isEmpty()) {
+                        // Not sure why the following is needed, but it has 
been observed in real projects.
+                        paths.add(outputDirectory.resolve(moduleName));
+                    }
+                    
fileManager.setLocationForModule(StandardLocation.PATCH_MODULE_PATH, 
moduleName, paths);
+                    
fileManager.setLocationForModule(StandardLocation.MODULE_SOURCE_PATH, 
moduleName, Set.of());
+                    iterator.remove();
+                } else {
+                    entry.setValue(Boolean.TRUE); // For compilation of next 
target version (if any).
+                }
             }
-        } catch (UncheckedIOException e) {
-            throw e.getCause();
         }
-        if (success && incrementalBuild != null) {
-            incrementalBuild.writeCache();
-            incrementalBuild = null;
+
+        /**
+         * Removes the first <var>n</var> elements of the given collection.
+         * This is used for removing {@code --patch-module} items that were 
added as source directories.
+         * The callers should replace the removed items by the output 
directory of these source files.
+         *
+         * @param paths  the paths from which to remove the first elements
+         * @param count  number of elements to remove, or {@code null} if none
+         * @return whether at least one item has been removed
+         */
+        private static boolean removeFirsts(Deque<Path> paths, Integer count) {
+            boolean changed = false;
+            if (count != null) {
+                for (int i = count; --i >= 0; ) {
+                    changed |= (paths.removeFirst() != null);
+                }
+            }
+            return changed;
         }
-        return success;
     }
 
     /**
-     * Subdivides a compilation unit into one or more compilation tasks.
-     * This is a workaround for deprecated practices such as overwriting the 
main {@code module-info} in the tests.
-     * In the latter case, we need to compile the test {@code module-info} 
separately, before the other test classes.
+     * Runs the compilation task.
+     *
+     * @param compiler the compiler
+     * @param configuration the options to give to the Java compiler
+     * @param otherOutput where to write additional output from the compiler
+     * @return whether the compilation succeeded
+     * @throws IOException if an error occurred while reading or writing a file
+     * @throws MojoException if the compilation failed for a reason identified 
by this method
+     * @throws RuntimeException if any other kind of  error occurred
      */
-    CompilationTaskSources[] toCompilationTasks(final SourcesForRelease unit) {
-        if (unit.files.isEmpty()) {
-            return new CompilationTaskSources[0];
+    public boolean compile(JavaCompiler compiler, final Options configuration, 
final Writer otherOutput)
+            throws IOException {
+
+        if (noSourcesToCompile()) {
+            return true;
         }
-        return new CompilationTaskSources[] {new 
CompilationTaskSources(unit.files)};
+
+        // Determine project type once from all units before processing.
+        final Collection<SourcesForRelease> units = groupByReleaseAndModule();
+        determineDirectoryHierarchy(units);
+
+        // Workaround for a `javax.tools` method which seems not yet supported 
on all compilers.
+        if (WorkaroundForPatchModule.ENABLED && hasModuleDeclaration && 
!(compiler instanceof ForkedTool)) {
+            compiler = new WorkaroundForPatchModule(compiler);
+        }
+        boolean success = true;
+        try (StandardJavaFileManager fileManager = 
compiler.getStandardFileManager(listener, LOCALE, encoding)) {
+            setDependencyPaths(fileManager);
+            if (!generatedSourceDirectories.isEmpty()) {
+                
fileManager.setLocationFromPaths(StandardLocation.SOURCE_OUTPUT, 
generatedSourceDirectories);
+            }
+            final PathManager pathManager =
+                    switch (directoryHierarchy) {
+                        case PACKAGE -> new PathManager(fileManager);
+                        case PACKAGE_WITH_MODULE, MODULE_SOURCE -> new 
ModulePathManager(fileManager);
+                    };
+
+            // Compile each release version in order (base version first for 
multi-release projects).
+            for (final SourcesForRelease unit : units) {
+                configuration.setRelease(unit.getReleaseString());
+                pathManager.configureSourcePaths(unit.roots);
+
+                // Snapshot dependencies for debug file
+                copyDependencyValues();
+                unit.dependencySnapshot = new LinkedHashMap<>(dependencies);
+
+                // Set up output directory and compile (only if there are 
files).
+                pathManager.setupOutputDirectory(unit);
+
+                // Compile the source files now.
+                if (!unit.files.isEmpty()) {
+                    Iterable<? extends JavaFileObject> sources = 
fileManager.getJavaFileObjectsFromPaths(unit.files);
+                    JavaCompiler.CompilationTask task;
+                    task = compiler.getTask(otherOutput, fileManager, 
listener, configuration.options, null, sources);
+                    success = task.call();
+                    if (!success) {
+                        break;
+                    }
+                }
+                pathManager.markVersioned();
+            }
+        } catch (UncheckedIOException e) {
+            throw e.getCause();
+        }
+
+        // Performs post-compilation tasks such as logging and writing 
incremental build cache.
+        if (listener instanceof DiagnosticLogger diagnostic) {
+            diagnostic.logSummary();
+        }
+        if (success) {
+            saveIncrementalBuild();
+        }
+        return success;
     }
 }
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 0eba178..5665882 100644
--- a/src/main/java/org/apache/maven/plugin/compiler/ToolExecutorForTest.java
+++ b/src/main/java/org/apache/maven/plugin/compiler/ToolExecutorForTest.java
@@ -18,6 +18,7 @@
  */
 package org.apache.maven.plugin.compiler;
 
+import javax.lang.model.SourceVersion;
 import javax.tools.DiagnosticListener;
 import javax.tools.JavaCompiler;
 import javax.tools.JavaFileObject;
@@ -26,21 +27,23 @@ import java.io.BufferedReader;
 import java.io.BufferedWriter;
 import java.io.IOException;
 import java.io.InputStream;
+import java.io.UncheckedIOException;
 import java.io.Writer;
 import java.lang.module.ModuleDescriptor;
 import java.nio.file.Files;
 import java.nio.file.Path;
+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 org.apache.maven.api.JavaPathType;
-import org.apache.maven.api.PathType;
-import org.apache.maven.api.ProjectScope;
+import java.util.NavigableMap;
+import java.util.TreeMap;
+import java.util.stream.Stream;
 
 import static 
org.apache.maven.plugin.compiler.AbstractCompilerMojo.SUPPORT_LEGACY;
+import static org.apache.maven.plugin.compiler.DirectoryHierarchy.META_INF;
+import static 
org.apache.maven.plugin.compiler.SourceDirectory.CLASS_FILE_SUFFIX;
+import static org.apache.maven.plugin.compiler.SourceDirectory.MODULE_INFO;
 
 /**
  * A task which configures and executes the Java compiler for the test classes.
@@ -55,12 +58,13 @@ class ToolExecutorForTest extends ToolExecutor {
      *
      * @see TestCompilerMojo#mainOutputDirectory
      */
-    protected final Path mainOutputDirectory;
+    private final Path mainOutputDirectory;
 
     /**
-     * Path to the {@code module-info.class} file of the main code, or {@code 
null} if that file does not exist.
+     * The main output directory of each module. This is usually {@code 
mainOutputDirectory/<module>},
+     * except if some modules are defined only for some Java versions higher 
than the base version.
      */
-    private final Path mainModulePath;
+    private final Map<String, Path> mainOutputDirectoryForModules;
 
     /**
      * Whether to place the main classes on the module path when {@code 
module-info} is present.
@@ -86,27 +90,18 @@ class ToolExecutorForTest extends ToolExecutor {
     private final boolean hasTestModuleInfo;
 
     /**
-     * Whether the tests are declared in their own module. If {@code true},
-     * then the {@code module-info.java} file of the test declares a name
-     * different than the {@code module-info.java} file of the main code.
-     */
-    private boolean testInItsOwnModule;
-
-    /**
-     * Whether the {@code module-info} of the tests overwrites the main {@code 
module-info}.
-     * This is a deprecated practice, but is accepted if {@link 
#SUPPORT_LEGACY} is true.
-     */
-    private boolean overwriteMainModuleInfo;
-
-    /**
-     * Name of the main module to compile, or {@code null} if not yet 
determined.
-     * If the project is not modular, then this field contains an empty string.
+     * Name of the module when using package hierarchy, or {@code null} if not 
applicable.
+     * This is used for setting {@code --patch-module} option during 
compilation of tests.
+     * This field is null in a class-path project or in a multi-module project.
      *
-     * TODO: use "*" as a sentinel value for modular source hierarchy.
+     * <p>This field exists mostly for compatibility with the Maven 3 way to 
build a modular project.
+     * It is recommended to use the {@code <sources>} element instead. We may 
remove this field in a
+     * future version if we abandon compatibility with the Maven 3 way to 
build modular projects.</p>
      *
-     * @see #getMainModuleName()
+     * @deprecated Declare modules in {@code <source>} elements instead.
      */
-    private String moduleName;
+    @Deprecated(since = "4.0.0")
+    private String moduleNameFromPackageHierarchy;
 
     /**
      * Whether {@link #addModuleOptions(Options)} has already been invoked.
@@ -133,107 +128,163 @@ class ToolExecutorForTest extends ToolExecutor {
      *
      * @param mojo the <abbr>MOJO</abbr> from which to take a snapshot
      * @param listener where to send compilation warnings, or {@code null} for 
the Maven logger
+     * @param mainModulePath path to the {@code module-info.class} file of the 
main code, or {@code null} if none
      * @throws MojoException if this constructor identifies an invalid 
parameter in the <abbr>MOJO</abbr>
      * @throws IOException if an error occurred while creating the output 
directory or scanning the source directories
      */
     @SuppressWarnings("deprecation")
-    ToolExecutorForTest(TestCompilerMojo mojo, DiagnosticListener<? super 
JavaFileObject> listener) throws IOException {
+    ToolExecutorForTest(
+            final TestCompilerMojo mojo,
+            final DiagnosticListener<? super JavaFileObject> listener,
+            final Path mainModulePath)
+            throws IOException {
         super(mojo, listener);
-        mainOutputDirectory = mojo.mainOutputDirectory;
-        mainModulePath = mojo.mainModulePath;
+        /*
+         * Notable work done by the parent constructor (examples with default 
paths):
+         *
+         *  - Set `outputDirectory` to a single "target/test-classes".
+         *  - Set `sourceDirectories` to many "src/<module>/test/java".
+         *  - Set `sourceFiles` to the content of `sourceDirectories`.
+         *  - Set `dependencies` with class-path and module-path, but not 
including main output directory.
+         *
+         * We will need to add the main output directory to the class-path or 
module-path, but not here.
+         * It will be done by `ToolExecutor.compile(…)` if 
`getOutputDirectoryOfPreviousPhase()` returns
+         * a non-null value.
+         */
         useModulePath = mojo.useModulePath;
         hasTestModuleInfo = mojo.hasTestModuleInfo;
+        mainOutputDirectory = mojo.mainOutputDirectory;
+        mainOutputDirectoryForModules = new HashMap<>();
+        if (Files.notExists(mainOutputDirectory)) {
+            return;
+        }
+        if (mainModulePath != null) {
+            try (InputStream in = Files.newInputStream(mainModulePath)) {
+                moduleNameFromPackageHierarchy = 
ModuleDescriptor.read(in).name();
+            }
+        }
+        // Following is non-null only for modular project using package 
hierarchy.
+        final String testModuleName = 
mojo.moduleNameFromPackageHierarchy(sourceDirectories);
+        if (testModuleName != null) {
+            moduleNameFromPackageHierarchy = testModuleName;
+        }
         /*
-         * If we are compiling the test classes of a modular project, add the 
`--patch-modules` options.
+         * If compiling the test classes of a modular project, we will need 
`--patch-modules` options.
          * In this case, the option values are directories of main class files 
of the patched module.
+         * This block only prepares an empty map for each module. Maps are 
filled in the next block.
          */
-        final var patchedModules = new LinkedHashMap<String, Set<Path>>();
+        final var patchedModules = new LinkedHashMap<String, 
NavigableMap<SourceVersion, Path>>();
         for (SourceDirectory dir : sourceDirectories) {
             String moduleToPatch = dir.moduleName;
             if (moduleToPatch == null) {
-                moduleToPatch = getMainModuleName();
-                if (moduleToPatch.isEmpty()) {
+                moduleToPatch = moduleNameFromPackageHierarchy;
+                if (moduleToPatch == null) {
                     continue; // No module-info found.
                 }
-                if (SUPPORT_LEGACY) {
-                    String testModuleName = 
mojo.getTestModuleName(sourceDirectories);
-                    if (testModuleName != null) {
-                        overwriteMainModuleInfo = 
testModuleName.equals(getMainModuleName());
-                        if (!overwriteMainModuleInfo) {
-                            testInItsOwnModule = true;
-                            continue; // The test classes are in their own 
module.
-                        }
-                    }
-                }
+                /*
+                 * Modular project using package hierarchy (Maven 3 way).
+                 * We will need to move directories after compilation for 
reproducing the Maven 3 output.
+                 */
                 directoryLevelToRemove = moduleToPatch;
             }
-            patchedModules.put(moduleToPatch, new LinkedHashSet<>()); // 
Signal that this module exists in the test.
+            if (testModuleName != null && 
!moduleToPatch.equals(testModuleName)) {
+                // Mix of package hierarchy and module source hierarchy.
+                throw new CompilationFailureException(
+                        "The \"" + testModuleName + "\" module must be 
declared in a <module> element of <sources>.");
+            }
+            patchedModules.put(moduleToPatch, new TreeMap<>()); // Signal that 
this module exists in the test.
+        }
+        // Shortcut for class-path projects.
+        if (patchedModules.isEmpty()) {
+            return;
         }
         /*
-         * The values of `patchedModules` are empty lists. Now, add the real 
paths to
-         * main class for each module that exists in both the main code and 
the test.
+         * The values of `patchedModules` are empty maps. Now, add the real 
paths to the
+         * main classes for each module that exists in both the main code and 
the tests.
+         * Note that a module may exist only in the 
`META-INF/versions-modular/` directory.
          */
-        mojo.getSourceRoots(ProjectScope.MAIN).forEach((root) -> {
-            root.module().ifPresent((moduleToPatch) -> {
-                Set<Path> paths = patchedModules.get(moduleToPatch);
-                if (paths != null) {
-                    Path path = root.targetPath().orElseGet(() -> 
Path.of(moduleToPatch));
-                    path = mainOutputDirectory.resolve(path);
-                    paths.add(path);
+        addDirectoryIfModule(
+                mainOutputDirectory, moduleNameFromPackageHierarchy, 
SourceVersion.RELEASE_0, patchedModules);
+        addModuleDirectories(mainOutputDirectory, SourceVersion.RELEASE_0, 
patchedModules);
+        Path versionsDirectory = 
DirectoryHierarchy.MODULE_SOURCE.outputDirectoryForReleases(mainOutputDirectory);
+        if (Files.exists(versionsDirectory)) {
+            List<Path> asList;
+            try (Stream<Path> paths = Files.list(versionsDirectory)) {
+                asList = paths.toList();
+            }
+            for (Path path : asList) {
+                SourceVersion version;
+                try {
+                    version = 
SourceDirectory.parse(path.getFileName().toString());
+                } catch (UnsupportedVersionException e) {
+                    logger.debug(e);
+                    continue;
                 }
-            });
-        });
-        patchedModules.values().removeIf(Set::isEmpty);
-        patchedModules.forEach((moduleToPatch, paths) -> {
-            
dependencies(JavaPathType.patchModule(moduleToPatch)).addAll(paths);
-        });
+                addModuleDirectories(path, version, patchedModules);
+            }
+        }
         /*
-         * If there is no module to patch, we probably have a non-modular 
project.
-         * In such case, we need to put the main output directory on the 
classpath.
-         * It may also be a modular project not declared in the `<source>` 
element.
+         * At this point, we finished to scan the main output directory for 
modules.
+         * Remembers the directories of each module. They are usually 
sub-directories
+         * of the main directory, but could also be in 
`META-INF/versions-modular/`.
          */
-        if (patchedModules.isEmpty() && Files.exists(mainOutputDirectory)) {
-            PathType pathType = JavaPathType.CLASSES;
-            if (hasModuleDeclaration) {
-                pathType = JavaPathType.MODULES;
-                if (!testInItsOwnModule) {
-                    String moduleToPatch = getMainModuleName();
-                    if (!moduleToPatch.isEmpty()) {
-                        pathType = JavaPathType.patchModule(moduleToPatch);
-                        directoryLevelToRemove = moduleToPatch;
-                    }
-                }
+        patchedModules.forEach((moduleToPatch, directories) -> {
+            Map.Entry<SourceVersion, Path> base = directories.firstEntry();
+            if (base != null) {
+                mainOutputDirectoryForModules.putIfAbsent(moduleToPatch, 
base.getValue());
             }
-            prependDependency(pathType, mainOutputDirectory);
-        }
+        });
     }
 
     /**
-     * {@return the module name of the main code, or an empty string if none}
-     * This method reads the module descriptor when first needed and caches 
the result.
-     * This used if the user did not specified an explicit {@code <module>} 
element in the sources.
+     * Performs a shallow scan of the given directory for modules.
+     * This method searches for {@code module-info.class} files.
+     *
+     * <p>The keys of the {@code addTo} map are module names. Values are paths 
for all versions where
+     * {@code module-info.class} has been found. Note that this is not an 
exhaustive list of paths for
+     * all versions, because most {@code versions} directories do not have a 
{@code module-info.class} file.
+     * Therefore, the {@code SortedMap} will usually contain only the base 
directory. But we check versions
+     * anyway because sometime, a module does not exist in the base directory 
and is first defined only for
+     * a higher version.</p>
+     *
+     * <p>This method adds paths to existing entries only, and ignores modules 
that are not already in the map.
+     * This is done that way for collecting modules that are both in the main 
code and in the tests.</p>
      *
-     * @throws IOException if the module descriptor cannot be read.
+     * @param directory the directory to scan
+     * @param version target Java version of the directory to add
+     * @param addTo where to add the module paths
+     * @throws IOException if an error occurred while scanning the directories
      */
-    private String getMainModuleName() throws IOException {
-        if (moduleName == null) {
-            if (mainModulePath != null) {
-                try (InputStream in = Files.newInputStream(mainModulePath)) {
-                    moduleName = ModuleDescriptor.read(in).name();
-                }
-            } else {
-                moduleName = "";
-            }
+    private void addModuleDirectories(
+            Path directory, SourceVersion version, Map<String, 
NavigableMap<SourceVersion, Path>> addTo)
+            throws IOException {
+
+        try (Stream<Path> paths = Files.list(directory)) {
+            paths.forEach(
+                    (path) -> addDirectoryIfModule(path, 
path.getFileName().toString(), version, addTo));
+        } catch (UncheckedIOException e) {
+            throw e.getCause();
         }
-        return moduleName;
     }
 
     /**
-     * If the given module name is empty, tries to infer a default module name.
+     * Adds the given directory in {@code addTo} if the directory contains a 
{@code module-info.class} file.
+     *
+     * @param directory the directory to scan
+     * @param moduleName name of the module to add
+     * @param version target Java version of the directory to add
+     * @param addTo where to add the module paths
      */
-    @Override
-    final String inferModuleNameIfMissing(String moduleName) throws 
IOException {
-        return (!testInItsOwnModule && moduleName.isEmpty()) ? 
getMainModuleName() : moduleName;
+    private static void addDirectoryIfModule(
+            Path directory,
+            String moduleName,
+            SourceVersion version,
+            Map<String, NavigableMap<SourceVersion, Path>> addTo) {
+
+        NavigableMap<SourceVersion, Path> versions = addTo.get(moduleName);
+        if (versions != null && 
Files.isRegularFile(directory.resolve(MODULE_INFO + CLASS_FILE_SUFFIX))) {
+            versions.putIfAbsent(version, directory);
+        }
     }
 
     /**
@@ -258,7 +309,7 @@ class ToolExecutorForTest extends ToolExecutor {
         final var patches = new LinkedHashMap<String, ModuleInfoPatch>();
         for (SourceDirectory source : sourceDirectories) {
             Path file = source.root.resolve(ModuleInfoPatch.FILENAME);
-            String module;
+            String moduleName;
             if (Files.notExists(file)) {
                 if (SUPPORT_LEGACY && useModulePath && hasTestModuleInfo && 
hasModuleDeclaration) {
                     /*
@@ -273,29 +324,30 @@ class ToolExecutorForTest extends ToolExecutor {
                  * 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()) {
+                moduleName = source.moduleName;
+                if (moduleName == null) {
+                    moduleName = moduleNameFromPackageHierarchy;
+                    if (moduleName == null) {
                         continue;
                     }
                 }
                 if (defaultInfo != null) {
-                    patches.putIfAbsent(module, null); // Remember that we 
will need to compute a value later.
+                    patches.putIfAbsent(moduleName, null); // Remember that we 
will need to compute a value later.
                     continue;
                 }
-                defaultInfo = new ModuleInfoPatch(module, info);
+                defaultInfo = new ModuleInfoPatch(moduleName, info);
                 defaultInfo.setToDefaults();
                 info = defaultInfo;
             } else {
-                info = new ModuleInfoPatch(getMainModuleName(), info);
+                info = new ModuleInfoPatch(moduleNameFromPackageHierarchy, 
info);
                 try (BufferedReader reader = Files.newBufferedReader(file)) {
                     info.load(reader);
                 }
-                module = info.getModuleName();
+                moduleName = info.getModuleName();
             }
-            if (patches.put(module, info) != null) {
-                throw new ModuleInfoPatchException("\"module-info-patch " + 
module + "\" is defined more than once.");
+            if (patches.put(moduleName, info) != null) {
+                throw new ModuleInfoPatchException(
+                        "\"module-info-patch " + moduleName + "\" is defined 
more than once.");
             }
         }
         /*
@@ -320,7 +372,7 @@ class ToolExecutorForTest extends ToolExecutor {
          */
         if (!patches.isEmpty()) {
             Path directory = // TODO: replace by Path.resolve(String, 
String...) with JDK22.
-                    
Files.createDirectories(outputDirectory.resolve("META-INF").resolve("maven"));
+                    
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);
@@ -350,48 +402,44 @@ class ToolExecutorForTest extends ToolExecutor {
     }
 
     /**
-     * Separates the compilation of {@code module-info} from other classes. 
This is needed when the
-     * {@code module-info} of the test classes overwrite the {@code 
module-info} of the main classes.
-     * In the latter case, we need to compile the test {@code module-info} 
first in order to substitute
-     * the main module-info by the test one before to compile the remaining 
test classes.
+     * Returns the output directory of the main classes. This is the directory 
to prepend to
+     * the class-path or module-path before to compile the classes managed by 
this executor.
+     *
+     * @return the directory to prepend to the class-path or module-path
      */
     @Override
-    final CompilationTaskSources[] toCompilationTasks(final SourcesForRelease 
unit) {
-        if (!(SUPPORT_LEGACY && useModulePath && hasTestModuleInfo && 
overwriteMainModuleInfo)) {
-            return super.toCompilationTasks(unit);
-        }
-        CompilationTaskSources moduleInfo = null;
-        final List<Path> files = unit.files;
-        for (int i = files.size(); --i >= 0; ) {
-            if (SourceDirectory.isModuleInfoSource(files.get(i))) {
-                moduleInfo = new 
CompilationTaskSources(List.of(files.remove(i)));
-                if (files.isEmpty()) {
-                    return new CompilationTaskSources[] {moduleInfo};
-                }
-                break;
-            }
-        }
-        if (files.isEmpty()) {
-            return new CompilationTaskSources[0];
-        }
-        var task = new CompilationTaskSources(files) {
-            /**
-             * Substitutes the main {@code module-info.class} by the test's 
one, compiles test classes,
-             * then restores the original {@code module-info.class}. The test 
{@code module-info.class}
-             * must have been compiled separately before this method is 
invoked.
-             */
-            @Override
-            boolean compile(JavaCompiler.CompilationTask task) throws 
IOException {
-                try (unit) {
-                    unit.substituteModuleInfos(mainOutputDirectory, 
outputDirectory);
-                    return super.compile(task);
-                }
+    Path getOutputDirectoryOfPreviousPhase() {
+        return mainOutputDirectory;
+    }
+
+    /**
+     * Returns the directory of the classes compiled for the specified module.
+     * If the project is multi-release, this method returns the directory for 
the base version.
+     *
+     * @param outputDirectory the output directory which is the root of modules
+     * @param moduleName the name of the module for which the class directory 
is desired
+     * @return directories of classes for the given module
+     */
+    @Override
+    Path resolveModuleOutputDirectory(Path outputDirectory, String moduleName) 
{
+        if (outputDirectory.equals(mainOutputDirectory)) {
+            Path path = mainOutputDirectoryForModules.get(moduleName);
+            if (path != null) {
+                return path;
             }
-        };
-        if (moduleInfo != null) {
-            return new CompilationTaskSources[] {moduleInfo, task};
-        } else {
-            return new CompilationTaskSources[] {task};
         }
+        return super.resolveModuleOutputDirectory(outputDirectory, moduleName);
+    }
+
+    /**
+     * Name of the module when using package hierarchy, or {@code null} if not 
applicable.
+     * This is null in a class-path project or in a multi-module project.
+     *
+     * @deprecated This information exists only for compatibility with the 
Maven 3 way to build a modular project.
+     */
+    @Override
+    @Deprecated(since = "4.0.0")
+    final String moduleNameFromPackageHierarchy() {
+        return moduleNameFromPackageHierarchy;
     }
 }
diff --git 
a/src/main/java/org/apache/maven/plugin/compiler/WorkaroundForPatchModule.java 
b/src/main/java/org/apache/maven/plugin/compiler/WorkaroundForPatchModule.java
index 88f8b04..8606d3d 100644
--- 
a/src/main/java/org/apache/maven/plugin/compiler/WorkaroundForPatchModule.java
+++ 
b/src/main/java/org/apache/maven/plugin/compiler/WorkaroundForPatchModule.java
@@ -18,8 +18,12 @@
  */
 package org.apache.maven.plugin.compiler;
 
+import javax.annotation.processing.Processor;
+import javax.lang.model.SourceVersion;
+import javax.tools.DiagnosticListener;
 import javax.tools.FileObject;
 import javax.tools.ForwardingJavaFileManager;
+import javax.tools.JavaCompiler;
 import javax.tools.JavaFileManager;
 import javax.tools.JavaFileObject;
 import javax.tools.StandardJavaFileManager;
@@ -27,12 +31,18 @@ import javax.tools.StandardLocation;
 
 import java.io.File;
 import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.UncheckedIOException;
+import java.io.Writer;
+import java.nio.charset.Charset;
 import java.nio.file.Path;
 import java.util.Arrays;
 import java.util.Collection;
-import java.util.HashMap;
-import java.util.HashSet;
 import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.Locale;
 import java.util.Map;
 import java.util.Set;
 
@@ -52,195 +62,343 @@ import org.apache.maven.api.JavaPathType;
  *
  * @author Martin Desruisseaux
  */
-final class WorkaroundForPatchModule extends 
ForwardingJavaFileManager<StandardJavaFileManager>
-        implements StandardJavaFileManager {
+final class WorkaroundForPatchModule implements JavaCompiler {
     /**
      * Set this flag to {@code false} for testing if this workaround is still 
necessary.
      */
     static final boolean ENABLED = true;
 
     /**
-     * All locations that have been successfully specified to the file manager 
through programmatic API.
-     * This set excludes the {@code PATCH_MODULE_PATH} locations which were 
defined using the workaround
-     * described in class Javadoc.
+     * The actual compiler provided by {@link javax.tools}.
      */
-    private final Set<JavaFileManager.Location> definedLocations;
+    private final JavaCompiler compiler;
 
     /**
-     * The locations that we had to define by formatting a {@code 
--patch-module} option.
-     * Keys are module names and values are the paths for the associated 
module.
-     */
-    private final Map<String, Collection<? extends Path>> patchesAsOption;
-
-    /**
-     * Whether the caller needs to create a new file manager.
-     * It happens when we have been unable to set a {@code --patch-module} 
option on the current file manager.
+     * Creates a new workaround as a wrapper for the given compiler.
+     *
+     * @param compiler the actual compiler provided by {@link javax.tools}
      */
-    private boolean needsNewFileManager;
+    WorkaroundForPatchModule(JavaCompiler compiler) {
+        this.compiler = compiler;
+    }
 
     /**
-     * Creates a new workaround for the given file manager.
+     * Forwards the call to the wrapped compiler.
+     *
+     * @return the name of the compiler tool
      */
-    WorkaroundForPatchModule(final StandardJavaFileManager fileManager) {
-        super(fileManager);
-        definedLocations = new HashSet<>();
-        patchesAsOption = new HashMap<>();
+    @Override
+    public String name() {
+        return compiler.name();
     }
 
     /**
-     * {@return the original file manager, or {@code null} if the caller needs 
to create a new one}
-     * The returned value is {@code null} when we have been unable to set a 
{@code --patch-module}
-     * option on the current file manager. In such case, the caller should 
create a new file manager
-     * and configure it with {@link #copyTo(StandardJavaFileManager)}.
+     * Forwards the call to the wrapped compiler.
+     *
+     * @return the source versions of the Java programming language supported 
by the compiler
      */
-    StandardJavaFileManager getFileManagerIfUsable() {
-        return needsNewFileManager ? null : fileManager;
+    @Override
+    public Set<SourceVersion> getSourceVersions() {
+        return compiler.getSourceVersions();
     }
 
     /**
-     * Copies the locations defined in this file manager to the given file 
manager.
+     * Forwards the call to the wrapped compiler.
      *
-     * @param target where to copy the locations
-     * @throws IOException if a location cannot be set on the target file 
manager
+     * @return whether the given option is supported and if so, the number of 
arguments the option takes
      */
-    void copyTo(final StandardJavaFileManager target) throws IOException {
-        for (JavaFileManager.Location location : definedLocations) {
-            target.setLocation(location, fileManager.getLocation(location));
-        }
-        for (Map.Entry<String, Collection<? extends Path>> entry : 
patchesAsOption.entrySet()) {
-            Collection<? extends Path> paths = entry.getValue();
-            String moduleName = entry.getKey();
-            try {
-                
target.setLocationForModule(StandardLocation.PATCH_MODULE_PATH, moduleName, 
paths);
-            } catch (UnsupportedOperationException e) {
-                specifyAsOption(target, JavaPathType.patchModule(moduleName), 
paths, e);
-            }
-        }
+    @Override
+    public int isSupportedOption(String option) {
+        return compiler.isSupportedOption(option);
     }
 
     /**
-     * Sets a module path by asking the file manager to parse an option 
formatted by this method.
-     * Invoked when a module path cannot be specified through the standard 
<abbr>API</abbr>.
-     * This is the workaround described in class Javadoc.
+     * Forwards the call to the wrapped compiler and wraps the file manager in 
a workaround.
      *
-     * @param fileManager the file manager on which an attempt to set the 
location has been made and failed
-     * @param type the type of path together with the module name
-     * @param paths the paths to set
-     * @param cause the exception that occurred when invoking the standard API
-     * @throws IllegalArgumentException if this workaround doesn't work neither
+     * @param diagnosticListener a listener for non-fatal diagnostics
+     * @param locale the locale to apply when formatting diagnostics
+     * @param charset the character set used for decoding bytes
+     * @return a file manager with workaround
      */
-    private static void specifyAsOption(
-            StandardJavaFileManager fileManager,
-            JavaPathType.Modular type,
-            Collection<? extends Path> paths,
-            UnsupportedOperationException cause)
-            throws IOException {
-
-        String message;
-        Iterator<String> it = Arrays.asList(type.option(paths)).iterator();
-        if (!fileManager.handleOption(it.next(), it)) {
-            message = "Failed to set the %s option for module %s";
-        } else if (it.hasNext()) {
-            message = "Unexpected number of arguments after the %s option for 
module %s";
-        } else {
-            return;
-        }
-        JavaPathType rawType = type.rawType();
-        throw new IllegalArgumentException(
-                String.format(message, 
rawType.option().orElse(rawType.name()), type.moduleName()), cause);
+    @Override
+    public StandardJavaFileManager getStandardFileManager(
+            DiagnosticListener<? super JavaFileObject> diagnosticListener, 
Locale locale, Charset charset) {
+        return new 
FileManager(compiler.getStandardFileManager(diagnosticListener, locale, 
charset), locale, charset);
     }
 
     /**
-     * Adds the given module path to the file manager.
-     * If we cannot do that using the programmatic API, formats as a 
command-line option.
+     * Forwards the call to the wrapped compiler and wraps the task in a 
workaround.
+     *
+     * @param out destination of additional output from the compiler
+     * @param fileManager a file manager created by {@code 
getStandardFileManager(…)}
+     * @param diagnosticListener a listener for non-fatal diagnostics
+     * @param options compiler options
+     * @param classes names of classes to be processed by annotation processing
+     * @param compilationUnits the compilation units to compile
+     * @return an object representing the compilation
      */
     @Override
-    public void setLocationForModule(
-            JavaFileManager.Location location, String moduleName, Collection<? 
extends Path> paths) throws IOException {
-
-        if (paths.isEmpty()) {
-            return;
-        }
-        final boolean isPatch = (location == 
StandardLocation.PATCH_MODULE_PATH);
-        if (isPatch && patchesAsOption.replace(moduleName, paths) != null) {
-            /*
-             * The patch was already specified by formatting the 
`--patch-module` option.
-             * We cannot do that again, because that option can appear only 
once per module.
-             */
-            needsNewFileManager = true;
-            return;
-        }
-        try {
-            fileManager.setLocationForModule(location, moduleName, paths);
-        } catch (UnsupportedOperationException e) {
-            if (isPatch) {
-                specifyAsOption(fileManager, 
JavaPathType.patchModule(moduleName), paths, e);
-                patchesAsOption.put(moduleName, paths);
-                return;
+    public CompilationTask getTask(
+            Writer out,
+            JavaFileManager fileManager,
+            DiagnosticListener<? super JavaFileObject> diagnosticListener,
+            Iterable<String> options,
+            Iterable<String> classes,
+            Iterable<? extends JavaFileObject> compilationUnits) {
+        if (fileManager instanceof FileManager wp) {
+            fileManager = wp.getFileManagerIfUsable();
+            if (fileManager == null) {
+                final StandardJavaFileManager workaround =
+                        compiler.getStandardFileManager(diagnosticListener, 
wp.locale, wp.charset);
+                try {
+                    wp.copyTo(workaround);
+                } catch (IOException e) {
+                    throw new UncheckedIOException(e);
+                }
+                final CompilationTask task =
+                        compiler.getTask(out, workaround, diagnosticListener, 
options, classes, compilationUnits);
+                return new CompilationTask() {
+                    @Override
+                    public void setLocale(Locale locale) {
+                        task.setLocale(locale);
+                    }
+
+                    @Override
+                    public void setProcessors(Iterable<? extends Processor> 
processors) {
+                        task.setProcessors(processors);
+                    }
+
+                    @Override
+                    public void addModules(Iterable<String> moduleNames) {
+                        task.addModules(moduleNames);
+                    }
+
+                    @Override
+                    public Boolean call() {
+                        final Boolean result = task.call();
+                        try {
+                            workaround.close();
+                        } catch (IOException e) {
+                            throw new UncheckedIOException(e);
+                        }
+                        return result;
+                    }
+                };
             }
-            throw e;
         }
-        definedLocations.add(fileManager.getLocationForModule(location, 
moduleName));
+        return compiler.getTask(out, fileManager, diagnosticListener, options, 
classes, compilationUnits);
     }
 
     /**
-     * Adds the given path to the file manager.
+     * Not used by the Maven Compiler Plugin.
      */
     @Override
-    public void setLocationFromPaths(JavaFileManager.Location location, 
Collection<? extends Path> paths)
-            throws IOException {
-        fileManager.setLocationFromPaths(location, paths);
-        definedLocations.add(location);
+    public int run(InputStream in, OutputStream out, OutputStream err, 
String... arguments) {
+        return compiler.run(in, out, err, arguments);
     }
 
-    @Override
-    public void setLocation(Location location, Iterable<? extends File> files) 
throws IOException {
-        fileManager.setLocation(location, files);
-        definedLocations.add(location);
-    }
+    /**
+     * A file manager which fallbacks on the {@code --patch-module}
+     * option when it cannot use {@link StandardLocation#PATCH_MODULE_PATH}.
+     * This is the class where the actual workaround is implemented.
+     */
+    private static final class FileManager extends 
ForwardingJavaFileManager<StandardJavaFileManager>
+            implements StandardJavaFileManager {
+        /**
+         * The locale specified by the user when creating this file manager.
+         * Saved for allowing the creation of other file managers.
+         */
+        final Locale locale;
 
-    @Override
-    public Iterable<? extends File> getLocation(Location location) {
-        return fileManager.getLocation(location);
-    }
+        /**
+         * The character set specified by the user when creating this file 
manager.
+         * Saved for allowing the creation of other file managers.
+         */
+        final Charset charset;
 
-    @Override
-    public Iterable<? extends Path> getLocationAsPaths(Location location) {
-        return fileManager.getLocationAsPaths(location);
-    }
+        /**
+         * All locations that have been successfully specified to the file 
manager through programmatic API.
+         * This set excludes the {@code PATCH_MODULE_PATH} locations which 
were defined using the workaround
+         * described in class Javadoc.
+         */
+        private final Set<JavaFileManager.Location> definedLocations;
 
-    @Override
-    public Iterable<? extends JavaFileObject> getJavaFileObjects(String... 
names) {
-        return fileManager.getJavaFileObjects(names);
-    }
+        /**
+         * The locations that we had to define by formatting a {@code 
--patch-module} option.
+         * Keys are module names and values are the paths for the associated 
module.
+         */
+        private final Map<String, Collection<? extends Path>> patchesAsOption;
 
-    @Override
-    public Iterable<? extends JavaFileObject> getJavaFileObjects(File... 
files) {
-        return fileManager.getJavaFileObjects(files);
-    }
+        /**
+         * Whether the caller needs to create a new file manager.
+         * It happens when we have been unable to set a {@code --patch-module} 
option on the current file manager.
+         */
+        private boolean needsNewFileManager;
 
-    @Override
-    public Iterable<? extends JavaFileObject> getJavaFileObjects(Path... 
paths) {
-        return fileManager.getJavaFileObjects(paths);
-    }
+        /**
+         * Creates a new workaround for the given file manager.
+         */
+        FileManager(StandardJavaFileManager fileManager, Locale locale, 
Charset charset) {
+            super(fileManager);
+            this.locale = locale;
+            this.charset = charset;
+            definedLocations = new LinkedHashSet<>();
+            patchesAsOption = new LinkedHashMap<>();
+        }
 
-    @Override
-    public Iterable<? extends JavaFileObject> 
getJavaFileObjectsFromStrings(Iterable<String> names) {
-        return fileManager.getJavaFileObjectsFromStrings(names);
-    }
+        /**
+         * {@return the original file manager, or {@code null} if the caller 
needs to create a new one}
+         * The returned value is {@code null} when we have been unable to set 
a {@code --patch-module}
+         * option on the current file manager. In such case, the caller should 
create a new file manager
+         * and configure it with {@link #copyTo(StandardJavaFileManager)}.
+         */
+        StandardJavaFileManager getFileManagerIfUsable() {
+            return needsNewFileManager ? null : fileManager;
+        }
 
-    @Override
-    public Iterable<? extends JavaFileObject> 
getJavaFileObjectsFromFiles(Iterable<? extends File> files) {
-        return fileManager.getJavaFileObjectsFromFiles(files);
-    }
+        /**
+         * Copies the locations defined in this file manager to the given file 
manager.
+         *
+         * @param target where to copy the locations
+         * @throws IOException if a location cannot be set on the target file 
manager
+         */
+        void copyTo(final StandardJavaFileManager target) throws IOException {
+            for (JavaFileManager.Location location : definedLocations) {
+                target.setLocation(location, 
fileManager.getLocation(location));
+            }
+            for (Map.Entry<String, Collection<? extends Path>> entry : 
patchesAsOption.entrySet()) {
+                Collection<? extends Path> paths = entry.getValue();
+                String moduleName = entry.getKey();
+                try {
+                    
target.setLocationForModule(StandardLocation.PATCH_MODULE_PATH, moduleName, 
paths);
+                } catch (UnsupportedOperationException e) {
+                    specifyAsOption(target, 
JavaPathType.patchModule(moduleName), paths, e);
+                }
+            }
+        }
 
-    @Override
-    public Iterable<? extends JavaFileObject> 
getJavaFileObjectsFromPaths(Collection<? extends Path> paths) {
-        return fileManager.getJavaFileObjectsFromPaths(paths);
-    }
+        /**
+         * Sets a module path by asking the file manager to parse an option 
formatted by this method.
+         * Invoked when a module path cannot be specified through the standard 
<abbr>API</abbr>.
+         * This is the workaround described in class Javadoc.
+         *
+         * @param fileManager the file manager on which an attempt to set the 
location has been made and failed
+         * @param type the type of path together with the module name
+         * @param paths the paths to set
+         * @param cause the exception that occurred when invoking the standard 
API
+         * @throws IllegalArgumentException if this workaround doesn't work 
neither
+         */
+        private static void specifyAsOption(
+                StandardJavaFileManager fileManager,
+                JavaPathType.Modular type,
+                Collection<? extends Path> paths,
+                UnsupportedOperationException cause)
+                throws IOException {
 
-    @Override
-    public Path asPath(FileObject file) {
-        return fileManager.asPath(file);
+            String message;
+            Iterator<String> it = Arrays.asList(type.option(paths)).iterator();
+            if (!fileManager.handleOption(it.next(), it)) {
+                message = "Failed to set the %s option for module %s";
+            } else if (it.hasNext()) {
+                message = "Unexpected number of arguments after the %s option 
for module %s";
+            } else {
+                return;
+            }
+            JavaPathType rawType = type.rawType();
+            throw new IllegalArgumentException(
+                    String.format(message, 
rawType.option().orElse(rawType.name()), type.moduleName()), cause);
+        }
+
+        /**
+         * Adds the given module path to the file manager.
+         * If we cannot do that using the programmatic API, formats as a 
command-line option.
+         */
+        @Override
+        public void setLocationForModule(
+                JavaFileManager.Location location, String moduleName, 
Collection<? extends Path> paths)
+                throws IOException {
+            if (location == StandardLocation.PATCH_MODULE_PATH) {
+                if (patchesAsOption.replace(moduleName, paths) != null) {
+                    /*
+                     * The patch was already specified by formatting the 
`--patch-module` option.
+                     * We cannot do that again, because that option can appear 
only once per module.
+                     * We nevertheless stored the new paths in 
`patchesAsOption` for use by `copyTo(…)`.
+                     */
+                    needsNewFileManager = true;
+                    return;
+                }
+                try {
+                    fileManager.setLocationForModule(location, moduleName, 
paths);
+                } catch (UnsupportedOperationException e) {
+                    specifyAsOption(fileManager, 
JavaPathType.patchModule(moduleName), paths, e);
+                    patchesAsOption.put(moduleName, paths);
+                    return;
+                }
+            } else {
+                fileManager.setLocationForModule(location, moduleName, paths);
+            }
+            definedLocations.add(fileManager.getLocationForModule(location, 
moduleName));
+        }
+
+        /**
+         * Adds the given path to the file manager.
+         */
+        @Override
+        public void setLocationFromPaths(JavaFileManager.Location location, 
Collection<? extends Path> paths)
+                throws IOException {
+            fileManager.setLocationFromPaths(location, paths);
+            definedLocations.add(location);
+        }
+
+        @Override
+        public void setLocation(Location location, Iterable<? extends File> 
files) throws IOException {
+            fileManager.setLocation(location, files);
+            definedLocations.add(location);
+        }
+
+        @Override
+        public Iterable<? extends File> getLocation(Location location) {
+            return fileManager.getLocation(location);
+        }
+
+        @Override
+        public Iterable<? extends Path> getLocationAsPaths(Location location) {
+            return fileManager.getLocationAsPaths(location);
+        }
+
+        @Override
+        public Iterable<? extends JavaFileObject> getJavaFileObjects(String... 
names) {
+            return fileManager.getJavaFileObjects(names);
+        }
+
+        @Override
+        public Iterable<? extends JavaFileObject> getJavaFileObjects(File... 
files) {
+            return fileManager.getJavaFileObjects(files);
+        }
+
+        @Override
+        public Iterable<? extends JavaFileObject> getJavaFileObjects(Path... 
paths) {
+            return fileManager.getJavaFileObjects(paths);
+        }
+
+        @Override
+        public Iterable<? extends JavaFileObject> 
getJavaFileObjectsFromStrings(Iterable<String> names) {
+            return fileManager.getJavaFileObjectsFromStrings(names);
+        }
+
+        @Override
+        public Iterable<? extends JavaFileObject> 
getJavaFileObjectsFromFiles(Iterable<? extends File> files) {
+            return fileManager.getJavaFileObjectsFromFiles(files);
+        }
+
+        @Override
+        public Iterable<? extends JavaFileObject> 
getJavaFileObjectsFromPaths(Collection<? extends Path> paths) {
+            return fileManager.getJavaFileObjectsFromPaths(paths);
+        }
+
+        @Override
+        public Path asPath(FileObject file) {
+            return fileManager.asPath(file);
+        }
     }
 }

Reply via email to