View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one
3    * or more contributor license agreements.  See the NOTICE file
4    * distributed with this work for additional information
5    * regarding copyright ownership.  The ASF licenses this file
6    * to you under the Apache License, Version 2.0 (the
7    * "License"); you may not use this file except in compliance
8    * with the License.  You may obtain a copy of the License at
9    *
10   *   http://www.apache.org/licenses/LICENSE-2.0
11   *
12   * Unless required by applicable law or agreed to in writing,
13   * software distributed under the License is distributed on an
14   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15   * KIND, either express or implied.  See the License for the
16   * specific language governing permissions and limitations
17   * under the License.
18   */
19  package org.apache.maven.plugin.compiler;
20  
21  import java.io.BufferedWriter;
22  import java.io.IOException;
23  import java.io.Reader;
24  import java.io.StreamTokenizer;
25  import java.lang.module.ModuleDescriptor;
26  import java.nio.file.Path;
27  import java.util.Collections;
28  import java.util.HashMap;
29  import java.util.LinkedHashMap;
30  import java.util.LinkedHashSet;
31  import java.util.List;
32  import java.util.Map;
33  import java.util.Set;
34  import java.util.StringJoiner;
35  
36  import org.apache.maven.api.Dependency;
37  import org.apache.maven.api.services.DependencyResolverResult;
38  
39  /**
40   * Reader of {@value #FILENAME} files.
41   * The main options managed by this class are the options that are not defined by Maven dependencies.
42   * They are the options for opening or exporting packages to other modules, or reading more modules.
43   * The values of these options are module names or package names.
44   * This class does not manage the options for which the value is a path.
45   *
46   * <h2>Global options</h2>
47   * The {@code --add-modules} and {@code --limit-modules} options are global, not options defined on a per-module basis.
48   * The global aspect is handled by using shared maps for the {@link #addModules} and {@link #limitModules} fields.
49   * The value of {@code --add-modules} is usually controlled by the dependencies declared in the {@code pom.xml} file
50   * and rarely needs to be modified.
51   *
52   * @author Martin Desruisseaux
53   */
54  final class ModuleInfoPatch {
55      /**
56       * Name of {@value} files that are parsed by this class.
57       */
58      public static final String FILENAME = "module-info-patch.maven";
59  
60      /**
61       * Maven-specific keyword for meaning to export a package to all the test module path.
62       * Other keywords such as {@code "ALL-MODULE-PATH"} are understood by the Java compiler.
63       */
64      private static final String TEST_MODULE_PATH = "TEST-MODULE-PATH";
65  
66      /**
67       * Maven-specific keyword for meaning to export a package to all other modules in the current Maven (sub)project.
68       * This is useful when a module contains a package of test fixtures also used for the tests in all other modules.
69       */
70      private static final String SUBPROJECT_MODULES = "SUBPROJECT-MODULES";
71  
72      /**
73       * Special cases for the {@code --add-modules} option.
74       * The {@value #TEST_MODULE_PATH} keyword is specific to Maven.
75       * Other keywords in this set are recognized by the Java compiler.
76       */
77      private static final Set<String> ADD_MODULES_SPECIAL_CASES = Set.of("ALL-MODULE-PATH", TEST_MODULE_PATH);
78  
79      /**
80       * Special cases for the {@code --add-exports} option.
81       * The {@value #TEST_MODULE_PATH} and {@value #SUBPROJECT_MODULES} keywords are specific to Maven.
82       * Other keywords in this set are recognized by the Java compiler.
83       */
84      private static final Set<String> ADD_EXPORTS_SPECIAL_CASES =
85              Set.of("ALL-UNNAMED", TEST_MODULE_PATH, SUBPROJECT_MODULES);
86  
87      /**
88       * The name of the module to patch, or {@code null} if unspecified.
89       *
90       * @see #getModuleName()
91       */
92      private String moduleName;
93  
94      /**
95       * Values parsed from the {@value #FILENAME} file for {@code --add-modules} option.
96       * A unique set is shared by {@code ModuleInfoPatch} instances of a project, because there
97       * is only one {@code --add-module} option applying to all modules. The values will be the
98       * union of the values provided by all {@value #FILENAME} files.
99       */
100     private final Set<String> addModules;
101 
102     /**
103      * Values parsed from the {@value #FILENAME} file for {@code --limit-modules} option.
104      * A unique set is shared by all {@code ModuleInfoPatch} instances of a project in the
105      * same way as {@link #addModules}.
106      */
107     private final Set<String> limitModules;
108 
109     /**
110      * Values parsed from the {@value #FILENAME} file for {@code --add-reads} option.
111      * Option values will be prefixed by {@link #moduleName}.
112      */
113     private final Set<String> addReads;
114 
115     /**
116      * Values parsed from the {@value #FILENAME} file for {@code --add-exports} option.
117      * Option values will be prefixed by {@link #moduleName}.
118      * Keys are package names.
119      */
120     private final Map<String, Set<String>> addExports;
121 
122     /**
123      * Values parsed from the {@value #FILENAME} file for {@code --add-opens} option.
124      * Option values will be prefixed by {@link #moduleName}.
125      * Keys are package names.
126      */
127     private final Map<String, Set<String>> addOpens;
128 
129     /**
130      * A clone of this {@code ModuleInfoPatch} but with runtime dependencies instead of compile-time dependencies.
131      * The information saved in this object are not used by the compiler plugin, because the runtime dependencies
132      * may differ from the runtime dependencies. But we need to save them for the needs of other plugins such as
133      * Surefire. If the compile and runtime dependencies are the same, then the value is {@code this}.
134      */
135     private ModuleInfoPatch runtimeDependencies;
136 
137     /**
138      * Creates an initially empty module patch.
139      *
140      * @param defaultModule  the name of the default module if there is no {@value #FILENAME}
141      * @param previous       the previous instance (for sharing global options), or {@code null} if none.
142      */
143     ModuleInfoPatch(String defaultModule, ModuleInfoPatch previous) {
144         if (defaultModule != null && !defaultModule.isBlank()) {
145             moduleName = defaultModule;
146         }
147         if (previous != null) {
148             addModules = previous.addModules;
149             limitModules = previous.limitModules;
150         } else {
151             addModules = new LinkedHashSet<>();
152             limitModules = new LinkedHashSet<>();
153         }
154         addReads = new LinkedHashSet<>();
155         addExports = new LinkedHashMap<>();
156         addOpens = new LinkedHashMap<>();
157         runtimeDependencies = this;
158     }
159 
160     /**
161      * Creates a deep clone of the given module info patch.
162      * This is used for initializing the {@link #runtimeDependencies} field.
163      *
164      * @param parent the module info patch to clone
165      */
166     private ModuleInfoPatch(ModuleInfoPatch parent) {
167         moduleName = parent.moduleName;
168         addModules = new LinkedHashSet<>(parent.addModules);
169         limitModules = new LinkedHashSet<>(parent.limitModules);
170         addReads = new LinkedHashSet<>(parent.addReads);
171         addExports = new LinkedHashMap<>(parent.addExports);
172         addOpens = new LinkedHashMap<>(parent.addOpens);
173         // Leave `runtimeDependencies` to null as it would be an error to use it a second time.
174     }
175 
176     /**
177      * Creates a module patch with the specified {@code --add-reads} options and everything else empty.
178      *
179      * @param addReads the {@code --add-reads} option
180      * @param moduleName the name of the module to patch
181      *
182      * @see #patchWithSameReads(String)
183      */
184     private ModuleInfoPatch(Set<String> addReads, String moduleName) {
185         this.moduleName = moduleName;
186         this.addReads = addReads;
187         /*
188          * Really need `Collections.emptyFoo()` here, not `Set.of()` or `Map.of()`.
189          * A difference is that the former silently accept calls to `clear()` as
190          * no-operation, while the latter throw `UnsupportedOperationException`.
191          */
192         addModules = Collections.emptySet();
193         limitModules = Collections.emptySet();
194         addExports = Collections.emptyMap();
195         addOpens = Collections.emptyMap();
196         // `runtimeDependencies` to be initialized by the caller.
197     }
198 
199     /**
200      * Sets this instance to the default configuration to use when no {@value #FILENAME} is present.
201      */
202     public void setToDefaults() {
203         addModules.add(TEST_MODULE_PATH);
204         addReads.add(TEST_MODULE_PATH);
205     }
206 
207     /**
208      * Loads the content of the given stream of characters.
209      * This method does not close the given reader.
210      *
211      * @param source stream of characters to read
212      * @throws IOException if an I/O error occurred while loading the file
213      */
214     public void load(Reader source) throws IOException {
215         var reader = new StreamTokenizer(source);
216         reader.slashSlashComments(true);
217         reader.slashStarComments(true);
218         expectToken(reader, "patch-module");
219         moduleName = nextName(reader, true);
220         expectToken(reader, '{');
221         while (reader.nextToken() == StreamTokenizer.TT_WORD) {
222             switch (reader.sval) {
223                 case "add-modules":
224                     readModuleList(reader, addModules, ADD_MODULES_SPECIAL_CASES);
225                     break;
226                 case "limit-modules":
227                     readModuleList(reader, limitModules, Set.of());
228                     break;
229                 case "add-reads":
230                     readModuleList(reader, addReads, Set.of(TEST_MODULE_PATH));
231                     break;
232                 case "add-exports":
233                     readQualified(reader, addExports, ADD_EXPORTS_SPECIAL_CASES);
234                     break;
235                 case "add-opens":
236                     readQualified(reader, addOpens, Set.of());
237                     break;
238                 default:
239                     throw new ModuleInfoPatchException("Unknown keyword \"" + reader.sval + '"', reader);
240             }
241         }
242         if (reader.ttype != '}') {
243             throw new ModuleInfoPatchException("Not a token", reader);
244         }
245         if (reader.nextToken() != StreamTokenizer.TT_EOF) {
246             throw new ModuleInfoPatchException("Expected end of file but found \"" + reader.sval + '"', reader);
247         }
248     }
249 
250     /**
251      * Skips a token which is expected to be equal to the given value.
252      *
253      * @param reader the reader from which to skip a token
254      * @param expected the expected token value
255      * @throws IOException if an I/O error occurred while loading the file
256      * @throws ModuleInfoPatchException if the next token does not have the expected value
257      */
258     private static void expectToken(StreamTokenizer reader, String expected) throws IOException {
259         if (reader.nextToken() != StreamTokenizer.TT_WORD || !expected.equals(reader.sval)) {
260             throw new ModuleInfoPatchException("Expected \"" + expected + '"', reader);
261         }
262     }
263 
264     /**
265      * Skips a token which is expected to be equal to the given value.
266      * The expected character must be flagged as an ordinary character in the reader.
267      *
268      * @param reader the reader from which to skip a token
269      * @param expected the expected character value
270      * @throws IOException if an I/O error occurred while loading the file
271      * @throws ModuleInfoPatchException if the next token does not have the expected value
272      */
273     private static void expectToken(StreamTokenizer reader, char expected) throws IOException {
274         if (reader.nextToken() != expected) {
275             throw new ModuleInfoPatchException("Expected \"" + expected + '"', reader);
276         }
277     }
278 
279     /**
280      * Returns the next package or module name.
281      * This method verifies that the name is non-empty and a valid Java identifier.
282      *
283      * @param reader the reader from which to get the package or module name
284      * @param module {@code true} is expecting a module name, {@code false} if expecting a package name
285      * @return the package or module name
286      * @throws IOException if an I/O error occurred while loading the file
287      * @throws ModuleInfoPatchException if the next token is not a package or module name
288      */
289     private static String nextName(StreamTokenizer reader, boolean module) throws IOException {
290         if (reader.nextToken() != StreamTokenizer.TT_WORD) {
291             throw new ModuleInfoPatchException("Expected a " + (module ? "module" : "package") + " name", reader);
292         }
293         return ensureValidName(reader, reader.sval.strip(), module);
294     }
295 
296     /**
297      * Verifies that the given name is a valid package or module identifier.
298      *
299      * @param reader the reader from which to get the line number if an exception needs to be thrown
300      * @param name the name to verify
301      * @param module {@code true} is expecting a module name, {@code false} if expecting a package name
302      * @throws ModuleInfoPatchException if the next token is not a package or module name
303      * @return the given name
304      */
305     private static String ensureValidName(StreamTokenizer reader, String name, boolean module) {
306         int length = name.length();
307         boolean expectFirstChar = true;
308         int c;
309         for (int i = 0; i < length; i += Character.charCount(c)) {
310             c = name.codePointAt(i);
311             if (expectFirstChar) {
312                 if (Character.isJavaIdentifierStart(c)) {
313                     expectFirstChar = false;
314                 } else {
315                     break; // Will throw exception because `expectFirstChar` is true.
316                 }
317             } else if (!Character.isJavaIdentifierPart(c)) {
318                 expectFirstChar = true;
319                 if (c != '.') {
320                     break; // Will throw exception because `expectFirstChar` is true.
321                 }
322             }
323         }
324         if (expectFirstChar) { // Also true if the name is empty
325             throw new ModuleInfoPatchException(
326                     "Invalid " + (module ? "module" : "package") + " name \"" + name + '"', reader);
327         }
328         return name;
329     }
330 
331     /**
332      * Reads a list of modules and stores the values in the given set.
333      *
334      * @param reader the reader from which to get the module names
335      * @param target where to store the module names
336      * @param specialCases special values to accept
337      * @return {@code target} or a new set if the target was initially null
338      * @throws IOException if an I/O error occurred while loading the file
339      * @throws ModuleInfoPatchException if the next token is not a module name
340      */
341     private static void readModuleList(StreamTokenizer reader, Set<String> target, Set<String> specialCases)
342             throws IOException {
343         do {
344             while (reader.nextToken() == StreamTokenizer.TT_WORD) {
345                 String module = reader.sval.strip();
346                 if (!specialCases.contains(module)) {
347                     module = ensureValidName(reader, module, true);
348                 }
349                 target.add(module);
350             }
351         } while (reader.ttype == ',');
352         if (reader.ttype != ';') {
353             throw new ModuleInfoPatchException("Missing ';' character", reader);
354         }
355     }
356 
357     /**
358      * Reads a package name followed by a list of modules names.
359      * Used for qualified exports or qualified opens.
360      *
361      * @param reader the reader from which to get the module names
362      * @param target where to store the module names
363      * @param specialCases special values to accept
364      * @throws IOException if an I/O error occurred while loading the file
365      * @throws ModuleInfoPatchException if the next token is not a module name
366      */
367     private static void readQualified(StreamTokenizer reader, Map<String, Set<String>> target, Set<String> specialCases)
368             throws IOException {
369         String packageName = nextName(reader, false);
370         expectToken(reader, "to");
371         readModuleList(reader, modulesForPackage(target, packageName), specialCases);
372     }
373 
374     /**
375      * {@return the set of modules associated to the given package name}
376      *
377      * @param target the map where to store the set of modules
378      * @param packageName the package name for which to get a set of modules
379      */
380     private static Set<String> modulesForPackage(Map<String, Set<String>> target, String packageName) {
381         return target.computeIfAbsent(packageName, (key) -> new LinkedHashSet<>());
382     }
383 
384     /**
385      * Bit mask for {@link #replaceTestModulePath(DependencyResolverResult)} internal usage.
386      */
387     private static final int COMPILE = 1;
388 
389     /**
390      * Bit mask for {@link #replaceTestModulePath(DependencyResolverResult)} internal usage.
391      */
392     private static final int RUNTIME = 2;
393 
394     /**
395      * Potentially adds the same value to compile and runtime sets.
396      * Whether to add a value is specified by the {@code scope} bitmask,
397      * which can contain a combination of {@link #COMPILE} and {@link #RUNTIME}.
398      *
399      * @param compile the collection where to add the value if the {@link #COMPILE} bit is set
400      * @param runtime the collection where to add the value if the {@link #RUNTIME} bit is set
401      * @param scope a combination of {@link #COMPILE} and {@link #RUNTIME} bits
402      * @param module the value to potentially add
403      * @return whether at least one collection has been modified
404      */
405     private static boolean addModuleName(Set<String> compile, Set<String> runtime, int scope, String module) {
406         boolean modified = false;
407         if ((scope & COMPILE) != 0) {
408             modified = compile.add(module);
409         }
410         if ((scope & RUNTIME) != 0 && compile != runtime) {
411             modified |= runtime.add(module);
412         }
413         return modified;
414     }
415 
416     /**
417      * Potentially adds the same value to compile and runtime exports.
418      * Whether to add a value is specified by the {@code scope} bitmask,
419      * which can contain a combination of {@link #COMPILE} and {@link #RUNTIME}.
420      *
421      * @param packageName name of the package to export
422      * @param scope a combination of {@link #COMPILE} and {@link #RUNTIME} bits
423      * @param module the module for which to export a package
424      * @return whether at least one collection has been modified
425      */
426     private boolean addExport(String packageName, int scope, String module) {
427         Set<String> compile = modulesForPackage(addExports, packageName);
428         Set<String> runtime = compile;
429         if (runtimeDependencies != this) {
430             runtime = modulesForPackage(runtimeDependencies.addExports, packageName);
431         }
432         return addModuleName(compile, runtime, scope, module);
433     }
434 
435     /**
436      * Replaces all occurrences of {@link #SUBPROJECT_MODULES} by the actual module names.
437      *
438      * @param sourceDirectories the test source directories for all modules in the project
439      */
440     public void replaceProjectModules(final List<SourceDirectory> sourceDirectories) {
441         for (Map.Entry<String, Set<String>> entry : addExports.entrySet()) {
442             if (entry.getValue().remove(SUBPROJECT_MODULES)) {
443                 for (final SourceDirectory source : sourceDirectories) {
444                     final String module = source.moduleName;
445                     if (module != null && !module.equals(moduleName)) {
446                         addExport(entry.getKey(), COMPILE | RUNTIME, module);
447                     }
448                 }
449             }
450         }
451     }
452 
453     /**
454      * Replaces all occurrences of {@link #TEST_MODULE_PATH} by the actual module names.
455      * These dependencies are automatically added to the {@code --add-modules} option once for all modules,
456      * then added to the {@code add-reads} option if the user specified the {@code TEST-MODULE-PATH} value.
457      * The latter is on a per-module basis. These options are also added implicitly if the user did not put
458      * a {@value #FILENAME} file in the test.
459      *
460      * @param dependencyResolution the result of resolving the dependencies, or {@code null} if none
461      * @throws IOException if an error occurred while reading information from a dependency
462      */
463     public void replaceTestModulePath(final DependencyResolverResult dependencyResolution) throws IOException {
464         final var exportsToTestModulePath = new LinkedHashSet<String>(); // Packages to export.
465         for (Map.Entry<String, Set<String>> entry : addExports.entrySet()) {
466             if (entry.getValue().remove(TEST_MODULE_PATH)) {
467                 exportsToTestModulePath.add(entry.getKey());
468             }
469         }
470         final boolean addAllTestModulePath = addModules.remove(TEST_MODULE_PATH);
471         final boolean readAllTestModulePath = addReads.remove(TEST_MODULE_PATH);
472         if (!addAllTestModulePath && !readAllTestModulePath && exportsToTestModulePath.isEmpty()) {
473             return; // Nothing to do.
474         }
475         if (dependencyResolution == null) {
476             // Note: we could log a warning, but we would need to ensure that it is logged only once.
477             return;
478         }
479         /*
480          * At this point, all `TEST-MODULE-PATCH` special values have been removed, but the actual module names
481          * have not yet been added. The module names may be added in two different instances. This instance is
482          * used for compile-time dependencies, while the `runtime` instance is used for runtime dependencies.
483          * The latter is created only if at least one dependency is different.
484          */
485         final var done = new HashMap<String, Integer>(); // Added modules and their dependencies.
486         for (Map.Entry<Dependency, Path> entry :
487                 dependencyResolution.getDependencies().entrySet()) {
488 
489             final int scope; // As a bitmask.
490             switch (entry.getKey().getScope()) {
491                 case TEST:
492                     scope = COMPILE | RUNTIME;
493                     break;
494                 case TEST_ONLY:
495                     scope = COMPILE;
496                     if (runtimeDependencies == this) {
497                         runtimeDependencies = new ModuleInfoPatch(this);
498                     }
499                     break;
500                 case TEST_RUNTIME:
501                     scope = RUNTIME;
502                     if (runtimeDependencies == this) {
503                         runtimeDependencies = new ModuleInfoPatch(this);
504                     }
505                     break;
506                 default:
507                     continue; // Skip non-test dependencies because they should already be in the main module-info.
508             }
509             Path dependencyPath = entry.getValue();
510             String module = dependencyResolution.getModuleName(dependencyPath).orElse(null);
511             if (module == null) {
512                 if (readAllTestModulePath) {
513                     addModuleName(addReads, runtimeDependencies.addReads, scope, "ALL-UNNAMED");
514                 }
515             } else if (mergeBit(done, module, scope)) {
516                 boolean modified = false;
517                 if (addAllTestModulePath) {
518                     modified |= addModuleName(addModules, runtimeDependencies.addModules, scope, module);
519                 }
520                 if (readAllTestModulePath) {
521                     modified |= addModuleName(addReads, runtimeDependencies.addReads, scope, module);
522                 }
523                 for (String packageName : exportsToTestModulePath) {
524                     modified |= addExport(packageName, scope, module);
525                 }
526                 /*
527                  * For making the options simpler, we do not add `--add-modules` or `--add-reads`
528                  * options for modules that are required by a module that we already added. This
529                  * simplification is not necessary, but makes the command-line easier to read.
530                  */
531                 if (modified) {
532                     dependencyResolution.getModuleDescriptor(dependencyPath).ifPresent((descriptor) -> {
533                         for (ModuleDescriptor.Requires r : descriptor.requires()) {
534                             done.merge(r.name(), scope, (o, n) -> o | n);
535                         }
536                     });
537                 }
538             }
539         }
540     }
541 
542     /**
543      * Sets the given bit in a map of bit masks.
544      *
545      * @param map the map where to set a bit
546      * @param key key of the entry for which to set a bit
547      * @param bit the bit to set
548      * @return whether the map changed as a result of this operation
549      */
550     private static boolean mergeBit(final Map<String, Integer> map, final String key, final int bit) {
551         Integer mask = map.putIfAbsent(key, bit);
552         if (mask != null) {
553             if ((mask & bit) != 0) {
554                 return false;
555             }
556             map.put(key, mask | bit);
557         }
558         return true;
559     }
560 
561     /**
562      * Returns a patch for another module with the same {@code --add-reads} options. All other options are empty.
563      * This is used when a {@code ModuleInfoPatch} instance has been created for the implicit options and the
564      * caller wants to replicate these default values to other modules declared in the {@code <sources>}.
565      *
566      * <h4>Constraint</h4>
567      * This method should be invoked <em>after</em> {@link #replaceTestModulePath(DependencyResolverResult)},
568      * otherwise the runtime dependencies derived from {@code TEST-MODULE-PaTH} may not be correct.
569      *
570      * @param otherModule the other module to patch, or {@code null} or empty if none
571      * @return patch for the other module, or {@code null} if {@code otherModule} was null or empty
572      */
573     public ModuleInfoPatch patchWithSameReads(String otherModule) {
574         if (otherModule == null || otherModule.isBlank()) {
575             return null;
576         }
577         var other = new ModuleInfoPatch(addReads, otherModule);
578         other.runtimeDependencies =
579                 (runtimeDependencies == this) ? other : new ModuleInfoPatch(runtimeDependencies.addReads, otherModule);
580         return other;
581     }
582 
583     /**
584      * {@return the name of the module to patch, or null if unspecified and no default}
585      */
586     public String getModuleName() {
587         return moduleName;
588     }
589 
590     /**
591      * Writes the values of the given option if the values is is non-null.
592      *
593      * @param option the option for which to write the values
594      * @param prefix prefix to write, followed by {@code '='}, before the value, or empty if none
595      * @param compile the values to write for the compiler, or {@code null} if none
596      * @param runtime the values to write for the Java launcher
597      * @param configuration where to write the option values for the compiler
598      * @param out where to write the option values for the Java launcher
599      */
600     private static void write(
601             String option,
602             String prefix,
603             Set<String> compile,
604             Set<String> runtime,
605             Options configuration,
606             BufferedWriter out)
607             throws IOException {
608         Set<String> values = runtime;
609         do {
610             if (!values.isEmpty()) {
611                 var buffer = new StringJoiner(",", (prefix != null) ? prefix + '=' : "", "");
612                 for (String value : values) {
613                     buffer.add(value);
614                 }
615                 if (values == compile) {
616                     configuration.addIfNonBlank("--" + option, buffer.toString());
617                 }
618                 if (values == runtime) {
619                     out.append("--").append(option).append(' ').append(buffer.toString());
620                     out.newLine();
621                 }
622             }
623         } while (values != compile && (values = compile) != null);
624     }
625 
626     /**
627      * Writes options that are qualified by module name and package name.
628      *
629      * @param option the option for which to write the values
630      * @param compile the values to write for the compiler, or {@code null} if none
631      * @param runtime the values to write for the Java launcher
632      * @param configuration where to write the option values for the compiler
633      * @param out where to write the option values for the Java launcher
634      */
635     private void write(
636             String option,
637             Map<String, Set<String>> compile,
638             Map<String, Set<String>> runtime,
639             Options configuration,
640             BufferedWriter out)
641             throws IOException {
642         Map<String, Set<String>> values = runtime;
643         do {
644             for (Map.Entry<String, Set<String>> entry : values.entrySet()) {
645                 String prefix = moduleName + '/' + entry.getKey();
646                 Set<String> otherModules = entry.getValue();
647                 write(
648                         option,
649                         prefix,
650                         (values == compile) ? otherModules : null,
651                         (values == runtime) ? otherModules : Set.of(),
652                         configuration,
653                         out);
654             }
655         } while (values != compile && (values = compile) != null);
656     }
657 
658     /**
659      * Writes the options.
660      *
661      * @param compile where to write the compile-time options
662      * @param runtime where to write the runtime options
663      */
664     public void writeTo(final Options compile, final BufferedWriter runtime) throws IOException {
665         write("add-modules", null, addModules, runtimeDependencies.addModules, compile, runtime);
666         write("limit-modules", null, limitModules, runtimeDependencies.limitModules, compile, runtime);
667         if (moduleName != null) {
668             write("add-reads", moduleName, addReads, runtimeDependencies.addReads, compile, runtime);
669             write("add-exports", addExports, runtimeDependencies.addExports, compile, runtime);
670             write("add-opens", null, runtimeDependencies.addOpens, compile, runtime);
671         }
672         addModules.clear(); // Add modules only once (this set is shared by other instances).
673         limitModules.clear();
674     }
675 }