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 javax.tools.OptionChecker;
22  
23  import java.io.IOException;
24  import java.io.UncheckedIOException;
25  import java.util.ArrayList;
26  import java.util.Arrays;
27  import java.util.Collection;
28  import java.util.List;
29  import java.util.Locale;
30  import java.util.function.UnaryOperator;
31  
32  import org.apache.maven.api.plugin.Log;
33  
34  /**
35   * An helper class for preparing the options to pass to the tool (compiler or document generator).
36   * It does <em>not</em> include the options related to paths (class-path, destination directory, <i>etc.</i>).
37   * If an option is unsupported by the tool, a message is logged at the warning level.
38   *
39   * @author Martin Desruisseaux
40   */
41  public final class Options {
42      /**
43       * The list of options with their values. For example, in order to compile for Java 17,
44       * {@code --release} and {@code 17} shall be two consecutive elements in this list.
45       */
46      final List<String> options;
47  
48      /**
49       * Index of the value of the {@code --release} parameter, or 0 if not present.
50       *
51       * @see #setRelease(String)
52       */
53      private int indexOfReleaseValue;
54  
55      /**
56       * The tools to use for checking whether an option is supported.
57       * It can be the Java compiler or the Javadoc generator.
58       */
59      private final OptionChecker checker;
60  
61      /**
62       * Where to report warnings about unsupported options.
63       */
64      private final Log logger;
65  
66      /**
67       * The warning message to log. This is used when a warning is not logged immediately,
68       * but deferred for allowing the caller to complete the message before to log.
69       */
70      private String warning;
71  
72      /**
73       * Creates an initially empty list of options.
74       *
75       * @param checker the tools to use for checking whether an option is supported
76       * @param logger where to report warnings about unsupported options
77       */
78      Options(OptionChecker checker, Log logger) {
79          options = new ArrayList<>();
80          this.checker = checker;
81          this.logger = logger;
82      }
83  
84      /**
85       * Strips white spaces and returns the result if non-empty, or {@code null} otherwise.
86       *
87       * @param value  the value from which to strip white spaces, or {@code null}
88       * @return the stripped value, or {@code null} if the value was null or blank
89       */
90      private static String strip(String value) {
91          if (value != null) {
92              value = value.strip();
93              if (value.isEmpty()) {
94                  return null;
95              }
96          }
97          return value;
98      }
99  
100     /**
101      * Adds or sets the value of the {@code --release} option. If this option was not present, it is added.
102      * If this option has already been specified, then its value is changed to the given value if non-null
103      * and non-blank, or removed otherwise.
104      *
105      * @param value value of the {@code --release} option, or {@code null} or empty if none
106      * @return whether the option has been added or defined
107      */
108     public boolean setRelease(String value) {
109         if (indexOfReleaseValue == 0) {
110             boolean added = addIfNonBlank("--release", value);
111             if (added) {
112                 indexOfReleaseValue = options.size() - 1;
113             }
114             return added;
115         }
116         value = strip(value);
117         if (value != null) {
118             options.set(indexOfReleaseValue, value);
119             return true;
120         }
121         options.subList(indexOfReleaseValue - 1, indexOfReleaseValue + 1).clear();
122         indexOfReleaseValue = 0;
123         return false;
124     }
125 
126     /**
127      * Adds the given option if the given value is true and the option is supported.
128      * If the option is unsupported, then a warning is logged and the option is not added.
129      *
130      * @param option the option (e.g. {@code --enable-preview})
131      * @param value value of the option
132      * @return whether the option has been added
133      */
134     public boolean addIfTrue(String option, boolean value) {
135         if (value && checkNumberOfArguments(option, 0, true)) {
136             options.add(option);
137             return true;
138         }
139         return false;
140     }
141 
142     /**
143      * Adds the given option if a non-null and non-blank value is provided and if the option is supported.
144      * If the option is unsupported by the tool, then a warning is logged and the option is not added.
145      *
146      * @param option the option (e.g., {@code --release})
147      * @param value value of the option, or {@code null} or blank if none
148      * @return whether the option has been added
149      */
150     public boolean addIfNonBlank(String option, String value) {
151         value = strip(value);
152         if (value != null) {
153             if (checkNumberOfArguments(option, 1, true)) {
154                 options.add(option);
155                 options.add(value);
156                 return true;
157             }
158         }
159         return false;
160     }
161 
162     /**
163      * Adds the given option using the {@code option:values} syntax where {@code values} is a coma-separated list.
164      * The option is added only if at least one non-blank value is provided. Values are converted to lower cases.
165      * Leading and trailing spaces are removed. If a filter is specified, then that filter will receive the values
166      * specified by the users and shall return the values to append, or {@code null} for not appending the option.
167      *
168      * @param option the option (e.g. {@code -g})
169      * @param values coma-separated values of the option, or {@code null} if none
170      * @param valids valid values for better error message when needed, or {@code null} if unspecified
171      * @param filter filter to apply on the values before to add them, or {@code null} if none
172      * @return whether the option has been added
173      */
174     public boolean addComaSeparated(
175             final String option, String values, Collection<String> valids, UnaryOperator<String[]> filter) {
176         if (values == null) {
177             return false;
178         }
179         /*
180          * Rebuild the comma-separated list of options with spaces removed, empty values omitted and case
181          * changed to lower-case. The split list will be reused for diagnostic if the option is not accepted.
182          */
183         String[] split = values.split(",");
184         int count = 0;
185         for (String value : split) {
186             value = value.strip();
187             if (!value.isEmpty()) {
188                 split[count++] = value.toLowerCase(Locale.US);
189             }
190         }
191         /*
192          * If a filter is specified, replace the user-specified list by the filtered one.
193          * The filtering may result in an empty list, which is interpreted as an option without value.
194          * This is different than an absence of user-supplied values, which is interpreted as no option.
195          * This subtle difference explains why the check for absence of values is done before filtering,
196          * and is needed for making possible to replace "-g:all" by "-g" (because the "all" value is a
197          * Maven addition).
198          */
199         if (count == 0) {
200             return false;
201         }
202         if (filter != null) {
203             if (count != split.length) {
204                 split = Arrays.copyOfRange(split, 0, count);
205             }
206             split = filter.apply(split);
207             if (split == null) {
208                 return false;
209             }
210             count = split.length;
211         }
212         /*
213          * Format the option (possibly with no values), then validate.
214          */
215         var sb = new StringBuilder(option);
216         for (int i = 0; i < count; i++) {
217             sb.append(i == 0 ? ':' : ',').append(split[i]);
218         }
219         String s = sb.toString();
220         if (checkNumberOfArguments(s, 0, false)) {
221             options.add(s);
222             return true;
223         }
224         /*
225          * A log message has been prepared in the `warning` field for saying that the option is not supported.
226          * If a collection of valid options was provided, use it for identifying which option was invalid.
227          */
228         if (valids != null) {
229             for (int i = 0; i < count; i++) {
230                 String value = split[i];
231                 if (!valids.contains(value)) {
232                     sb.setLength(0);
233                     sb.append(warning);
234                     sb.setLength(sb.length() - 1); // Remove the trailing dot.
235                     sb.append(", because the specified ")
236                             .append(option)
237                             .append(" value '")
238                             .append(value)
239                             .append("' is unexpected. Legal values are: ");
240                     int j = 0;
241                     for (String valid : valids) {
242                         if (j++ != 0) {
243                             sb.append(", ");
244                             if (j == valids.size()) {
245                                 sb.append("and ");
246                             }
247                         }
248                         sb.append('\'').append(valid).append('\'');
249                     }
250                     warning = sb.append('.').toString();
251                     break;
252                 }
253             }
254         }
255         logger.warn(warning);
256         warning = null;
257         return false;
258     }
259 
260     /**
261      * Verifies the validity of the given memory setting and adds it as an option.
262      * If the value has no units and Maven defaults are enabled, appends "M" as the default units of measurement.
263      * Note: in the International System of Units, the symbol shall be upper-case "M". The lower-case "m" symbol
264      * is not correct as it stands for "milli".
265      *
266      * @param option the option (e.g. {@code -J-Xms})
267      * @param label name of the XML element or attribute, used only if a warning message needs to be produced
268      * @param value the memory setting, or {@code null} if none
269      * @param addDefaultUnit whether to add a default unit (currently 'M') if none is provided
270      * @return whether the option has been added
271      */
272     public boolean addMemoryValue(String option, String label, String value, boolean addDefaultUnit) {
273         value = strip(value);
274         if (value != null) {
275             int length = value.length();
276             for (int i = 0; i < length; i++) {
277                 char c = value.charAt(i);
278                 if (c < '0' || c > '9') { // Do no use `isDigit(…)` because we do not accept other languages.
279                     if (i == length - 1) {
280                         c = Character.toUpperCase(c);
281                         if (c == 'K' || c == 'M' || c == 'G') {
282                             addDefaultUnit = false;
283                             break;
284                         }
285                     }
286                     logger.warn("Invalid value for " + label + "=\"" + value + "\". Ignoring this option.");
287                     return false;
288                 }
289             }
290             if (addDefaultUnit) {
291                 value += 'M'; // Upper case because this is the correct case in International System of Units.
292                 logger.warn("Value " + label + "=\"" + value + "\" has been specified without unit. "
293                         + "An explicit \"M\" unit symbol should be appended for avoiding ambiguity.");
294             }
295             option += value;
296             if (checkNumberOfArguments(option, 0, true)) {
297                 options.add(option);
298                 return true;
299             }
300         }
301         return false;
302     }
303 
304     /**
305      * Verifies if the given option is supported and accepts the given number of arguments.
306      * If not, a warning is logged if {@code immediate} is {@code true}, or stored in the
307      * {@link #warning} field if {@code immediate} is {@code false}.
308      *
309      * <p>If a message is stored in {@link #warning}, then it will always end with a dot.
310      * This guarantee allows callers to delete the last character and replace it by a coma
311      * for continuing the sentence.</p>
312      *
313      * @param option the option to validate
314      * @param count the number of arguments that the caller wants to provide
315      * @param immediate whether to log immediately or to store the message in {@link #warning}
316      * @return whether the given option is supported and accepts the specified number of arguments
317      */
318     private boolean checkNumberOfArguments(String option, int count, boolean immediate) {
319         int expected = checker.isSupportedOption(option);
320         if (expected == count) {
321             warning = null;
322             return true;
323         } else if (expected < 1) {
324             if (checker instanceof ForkedCompiler) {
325                 return true; // That implementation actually knows nothing about which options are supported.
326             }
327             warning = "The '" + option + "' option is not supported.";
328         } else if (expected == 0) {
329             warning = "The '" + option + "' option does not expect any argument.";
330         } else if (expected == 1) {
331             warning = "The '" + option + "' option expects a single argument.";
332         } else {
333             warning = "The '" + option + "' option expects " + expected + " arguments.";
334         }
335         if (immediate) {
336             logger.warn(warning);
337             warning = null;
338         }
339         return false;
340     }
341 
342     /**
343      * Adds the non-null and non-empty elements without verifying their validity.
344      * This method is used for user-specified compiler arguments.
345      *
346      * @param arguments the arguments to add, or {@code null} or empty if none
347      */
348     public void addUnchecked(Iterable<String> arguments) {
349         if (arguments != null) {
350             for (String arg : arguments) {
351                 if (arg != null) {
352                     arg = arg.strip();
353                     if (!arg.isEmpty()) {
354                         options.add(arg);
355                     }
356                 }
357             }
358         }
359     }
360 
361     /**
362      * Splits the given arguments around spaces, then adds them without verifying their validity.
363      * This is used for user-specified arguments.
364      *
365      * @param arguments the arguments to add, or {@code null} if none
366      *
367      * @deprecated Use {@link #addUnchecked(List)} instead. This method does not check for quoted strings.
368      */
369     @Deprecated(since = "4.0.0")
370     void addUnchecked(String arguments) {
371         if (arguments != null) {
372             addUnchecked(Arrays.asList(arguments.split(" ")));
373         }
374     }
375 
376     /**
377      * Formats the options for debugging purposes.
378      *
379      * @param commandLine the prefix where to put the {@code -J} options before all other options
380      * @param out where to put all options other than {@code -J}
381      * @throws IOException if an error occurred while writing an option
382      */
383     void format(final StringBuilder commandLine, final Appendable out) throws IOException {
384         boolean hasOptions = false;
385         for (String option : options) {
386             if (option.isBlank()) {
387                 continue;
388             }
389             if (option.startsWith("-J")) {
390                 if (commandLine != null) {
391                     if (commandLine.length() != 0) {
392                         commandLine.append(' ');
393                     }
394                     commandLine.append(option);
395                 }
396                 continue;
397             }
398             if (hasOptions) {
399                 if (option.charAt(0) == '-') {
400                     out.append(System.lineSeparator());
401                 } else {
402                     out.append(' ');
403                 }
404             }
405             boolean needsQuote = option.indexOf(' ') >= 0;
406             if (needsQuote) {
407                 out.append('"');
408             }
409             out.append(option);
410             if (needsQuote) {
411                 out.append('"');
412             }
413             hasOptions = true;
414         }
415         if (hasOptions) {
416             out.append(System.lineSeparator());
417         }
418     }
419 
420     /**
421      * {@return a string representatation of the options for debugging purposes}
422      */
423     @Override
424     public String toString() {
425         var out = new StringBuilder(40);
426         try {
427             format(out, out);
428         } catch (IOException e) {
429             throw new UncheckedIOException(e);
430         }
431         return out.toString();
432     }
433 }