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.wrapper.cli;
20  
21  import java.io.OutputStreamWriter;
22  import java.io.PrintWriter;
23  import java.io.Writer;
24  import java.util.ArrayList;
25  import java.util.Arrays;
26  import java.util.Collection;
27  import java.util.Collections;
28  import java.util.Comparator;
29  import java.util.Formatter;
30  import java.util.HashMap;
31  import java.util.HashSet;
32  import java.util.LinkedHashMap;
33  import java.util.List;
34  import java.util.Map;
35  import java.util.Set;
36  import java.util.TreeSet;
37  
38  /**
39   * <p>
40   * A command-line parser which supports a command/sub-command style command-line interface. Supports the following
41   * syntax:
42   * </p>
43   *
44   * <pre>
45   * &lt;option&gt;* (&lt;sub-command&gt; &lt;sub-command-option&gt;*)*
46   * </pre>
47   * <ul>
48   * <li>Short options are a '-' followed by a single character. For example: {@code -a}.</li>
49   * <li>Long options are '--' followed by multiple characters. For example: {@code --long-option}.</li>
50   * <li>Options can take arguments. The argument follows the option. For example: {@code -a arg} or
51   * {@code --long arg}.</li>
52   * <li>Arguments can be attached to the option using '='. For example: {@code -a=arg} or {@code --long=arg}.</li>
53   * <li>Arguments can be attached to short options. For example: {@code -aarg}.</li>
54   * <li>Short options can be combined. For example {@code -ab} is equivalent to {@code -a -b}.</li>
55   * <li>Anything else is treated as an extra argument. This includes a single {@code -} character.</li>
56   * <li>'--' indicates the end of the options. Anything following is not parsed and is treated as extra arguments.</li>
57   * <li>The parser is forgiving, and allows '--' to be used with short options and '-' to be used with long options.</li>
58   * <li>The set of options must be known at parse time. Sub-commands and their options do not need to be known at parse
59   * time. Use {@link ParsedCommandLine#getExtraArguments()} to obtain the non-option command-line arguments.</li>
60   * </ul>
61   */
62  public class CommandLineParser {
63      private Map<String, CommandLineOption> optionsByString = new HashMap<>();
64  
65      private boolean allowMixedOptions;
66  
67      private boolean allowUnknownOptions;
68  
69      private final PrintWriter deprecationPrinter;
70  
71      public CommandLineParser() {
72          this(new OutputStreamWriter(System.out));
73      }
74  
75      public CommandLineParser(Writer deprecationPrinter) {
76          this.deprecationPrinter = new PrintWriter(deprecationPrinter);
77      }
78  
79      /**
80       * Parses the given command-line.
81       *
82       * @param commandLine The command-line.
83       * @return The parsed command line.
84       * @throws org.apache.maven.wrapper.cli.CommandLineArgumentException On parse failure.
85       */
86      public ParsedCommandLine parse(String... commandLine) throws CommandLineArgumentException {
87          return parse(Arrays.asList(commandLine));
88      }
89  
90      /**
91       * Parses the given command-line.
92       *
93       * @param commandLine The command-line.
94       * @return The parsed command line.
95       * @throws org.apache.maven.wrapper.cli.CommandLineArgumentException On parse failure.
96       */
97      public ParsedCommandLine parse(Iterable<String> commandLine) throws CommandLineArgumentException {
98          ParsedCommandLine parsedCommandLine =
99                  new ParsedCommandLine(new HashSet<CommandLineOption>(optionsByString.values()));
100         ParserState parseState = new BeforeFirstSubCommand(parsedCommandLine);
101         for (String arg : commandLine) {
102             if (parseState.maybeStartOption(arg)) {
103                 if ("--".equals(arg)) {
104                     parseState = new AfterOptions(parsedCommandLine);
105                 } else if (arg.matches("--[^=]+")) {
106                     OptionParserState parsedOption = parseState.onStartOption(arg, arg.substring(2));
107                     parseState = parsedOption.onStartNextArg();
108                 } else if (arg.matches("--[^=]+=.*")) {
109                     int endArg = arg.indexOf('=');
110                     OptionParserState parsedOption = parseState.onStartOption(arg, arg.substring(2, endArg));
111                     parseState = parsedOption.onArgument(arg.substring(endArg + 1));
112                 } else if (arg.matches("-[^=]=.*")) {
113                     OptionParserState parsedOption = parseState.onStartOption(arg, arg.substring(1, 2));
114                     parseState = parsedOption.onArgument(arg.substring(3));
115                 } else {
116                     assert arg.matches("-[^-].*");
117                     String option = arg.substring(1);
118                     if (optionsByString.containsKey(option)) {
119                         OptionParserState parsedOption = parseState.onStartOption(arg, option);
120                         parseState = parsedOption.onStartNextArg();
121                     } else {
122                         String option1 = arg.substring(1, 2);
123                         OptionParserState parsedOption;
124                         if (optionsByString.containsKey(option1)) {
125                             parsedOption = parseState.onStartOption("-" + option1, option1);
126                             if (parsedOption.getHasArgument()) {
127                                 parseState = parsedOption.onArgument(arg.substring(2));
128                             } else {
129                                 parseState = parsedOption.onComplete();
130                                 for (int i = 2; i < arg.length(); i++) {
131                                     String optionStr = arg.substring(i, i + 1);
132                                     parsedOption = parseState.onStartOption("-" + optionStr, optionStr);
133                                     parseState = parsedOption.onComplete();
134                                 }
135                             }
136                         } else {
137                             if (allowUnknownOptions) {
138                                 // if we are allowing unknowns, just pass through the whole arg
139                                 parsedOption = parseState.onStartOption(arg, option);
140                                 parseState = parsedOption.onComplete();
141                             } else {
142                                 // We are going to throw a CommandLineArgumentException below, but want the message
143                                 // to reflect that we didn't recognise the first char (i.e. the option specifier)
144                                 parsedOption = parseState.onStartOption("-" + option1, option1);
145                                 parseState = parsedOption.onComplete();
146                             }
147                         }
148                     }
149                 }
150             } else {
151                 parseState = parseState.onNonOption(arg);
152             }
153         }
154 
155         parseState.onCommandLineEnd();
156         return parsedCommandLine;
157     }
158 
159     public CommandLineParser allowMixedSubcommandsAndOptions() {
160         allowMixedOptions = true;
161         return this;
162     }
163 
164     public CommandLineParser allowUnknownOptions() {
165         allowUnknownOptions = true;
166         return this;
167     }
168 
169     /**
170      * Prints a usage message to the given stream.
171      *
172      * @param out The output stream to write to.
173      */
174     public void printUsage(Appendable out) {
175         Formatter formatter = new Formatter(out);
176         Set<CommandLineOption> orderedOptions = new TreeSet<>(new OptionComparator());
177         orderedOptions.addAll(optionsByString.values());
178         Map<String, String> lines = new LinkedHashMap<>();
179         for (CommandLineOption option : orderedOptions) {
180             Set<String> orderedOptionStrings = new TreeSet<>(new OptionStringComparator());
181             orderedOptionStrings.addAll(option.getOptions());
182             List<String> prefixedStrings = new ArrayList<>();
183             for (String optionString : orderedOptionStrings) {
184                 if (optionString.length() == 1) {
185                     prefixedStrings.add("-" + optionString);
186                 } else {
187                     prefixedStrings.add("--" + optionString);
188                 }
189             }
190 
191             String key = join(prefixedStrings, ", ");
192             String value = option.getDescription();
193             if (value == null || value.length() == 0) {
194                 value = "";
195             }
196 
197             lines.put(key, value);
198         }
199         int max = 0;
200         for (String optionStr : lines.keySet()) {
201             max = Math.max(max, optionStr.length());
202         }
203         for (Map.Entry<String, String> entry : lines.entrySet()) {
204             if (entry.getValue().length() == 0) {
205                 formatter.format("%s%n", entry.getKey());
206             } else {
207                 formatter.format("%-" + max + "s  %s%n", entry.getKey(), entry.getValue());
208             }
209         }
210         formatter.flush();
211     }
212 
213     private static String join(Collection<?> things, String separator) {
214         StringBuffer buffer = new StringBuffer();
215         boolean first = true;
216 
217         if (separator == null) {
218             separator = "";
219         }
220 
221         for (Object thing : things) {
222             if (!first) {
223                 buffer.append(separator);
224             }
225             buffer.append(thing.toString());
226             first = false;
227         }
228         return buffer.toString();
229     }
230 
231     /**
232      * Defines a new option. By default, the option takes no arguments and has no description.
233      *
234      * @param options The options values.
235      * @return The option, which can be further configured.
236      */
237     public CommandLineOption option(String... options) {
238         for (String option : options) {
239             if (optionsByString.containsKey(option)) {
240                 throw new IllegalArgumentException(String.format("Option '%s' is already defined.", option));
241             }
242             if (option.startsWith("-")) {
243                 throw new IllegalArgumentException(
244                         String.format("Cannot add option '%s' as an option cannot" + " start with '-'.", option));
245             }
246         }
247         CommandLineOption option = new CommandLineOption(Arrays.asList(options));
248         for (String optionStr : option.getOptions()) {
249             this.optionsByString.put(optionStr, option);
250         }
251         return option;
252     }
253 
254     private static final class OptionString {
255         private final String arg;
256 
257         private final String option;
258 
259         private OptionString(String arg, String option) {
260             this.arg = arg;
261             this.option = option;
262         }
263 
264         public String getDisplayName() {
265             return arg.startsWith("--") ? "--" + option : "-" + option;
266         }
267 
268         @Override
269         public String toString() {
270             return getDisplayName();
271         }
272     }
273 
274     private abstract static class ParserState {
275         public abstract boolean maybeStartOption(String arg);
276 
277         boolean isOption(String arg) {
278             return arg.matches("-.+");
279         }
280 
281         public abstract OptionParserState onStartOption(String arg, String option);
282 
283         public abstract ParserState onNonOption(String arg);
284 
285         public void onCommandLineEnd() {}
286     }
287 
288     private abstract class OptionAwareParserState extends ParserState {
289         protected final ParsedCommandLine commandLine;
290 
291         protected OptionAwareParserState(ParsedCommandLine commandLine) {
292             this.commandLine = commandLine;
293         }
294 
295         @Override
296         public boolean maybeStartOption(String arg) {
297             return isOption(arg);
298         }
299 
300         @Override
301         public ParserState onNonOption(String arg) {
302             commandLine.addExtraValue(arg);
303             return allowMixedOptions ? new AfterFirstSubCommand(commandLine) : new AfterOptions(commandLine);
304         }
305     }
306 
307     private final class BeforeFirstSubCommand extends OptionAwareParserState {
308         private BeforeFirstSubCommand(ParsedCommandLine commandLine) {
309             super(commandLine);
310         }
311 
312         @Override
313         public OptionParserState onStartOption(String arg, String option) {
314             OptionString optionString = new OptionString(arg, option);
315             CommandLineOption commandLineOption = optionsByString.get(option);
316             if (commandLineOption == null) {
317                 if (allowUnknownOptions) {
318                     return new UnknownOptionParserState(arg, commandLine, this);
319                 } else {
320                     throw new CommandLineArgumentException(
321                             String.format("Unknown command-line option '%s'.", optionString));
322                 }
323             }
324             return new KnownOptionParserState(optionString, commandLineOption, commandLine, this);
325         }
326     }
327 
328     private final class AfterFirstSubCommand extends OptionAwareParserState {
329         private AfterFirstSubCommand(ParsedCommandLine commandLine) {
330             super(commandLine);
331         }
332 
333         @Override
334         public OptionParserState onStartOption(String arg, String option) {
335             CommandLineOption commandLineOption = optionsByString.get(option);
336             if (commandLineOption == null) {
337                 return new UnknownOptionParserState(arg, commandLine, this);
338             }
339             return new KnownOptionParserState(new OptionString(arg, option), commandLineOption, commandLine, this);
340         }
341     }
342 
343     private static final class AfterOptions extends ParserState {
344         private final ParsedCommandLine commandLine;
345 
346         private AfterOptions(ParsedCommandLine commandLine) {
347             this.commandLine = commandLine;
348         }
349 
350         @Override
351         public boolean maybeStartOption(String arg) {
352             return false;
353         }
354 
355         @Override
356         public OptionParserState onStartOption(String arg, String option) {
357             return new UnknownOptionParserState(arg, commandLine, this);
358         }
359 
360         @Override
361         public ParserState onNonOption(String arg) {
362             commandLine.addExtraValue(arg);
363             return this;
364         }
365     }
366 
367     private static final class MissingOptionArgState extends ParserState {
368         private final OptionParserState option;
369 
370         private MissingOptionArgState(OptionParserState option) {
371             this.option = option;
372         }
373 
374         @Override
375         public boolean maybeStartOption(String arg) {
376             return isOption(arg);
377         }
378 
379         @Override
380         public OptionParserState onStartOption(String arg, String option) {
381             return this.option.onComplete().onStartOption(arg, option);
382         }
383 
384         @Override
385         public ParserState onNonOption(String arg) {
386             return option.onArgument(arg);
387         }
388 
389         @Override
390         public void onCommandLineEnd() {
391             option.onComplete();
392         }
393     }
394 
395     private abstract static class OptionParserState {
396         public abstract ParserState onStartNextArg();
397 
398         public abstract ParserState onArgument(String argument);
399 
400         public abstract boolean getHasArgument();
401 
402         public abstract ParserState onComplete();
403     }
404 
405     private final class KnownOptionParserState extends OptionParserState {
406         private final OptionString optionString;
407 
408         private final CommandLineOption option;
409 
410         private final ParsedCommandLine commandLine;
411 
412         private final ParserState state;
413 
414         private final List<String> values = new ArrayList<>();
415 
416         private KnownOptionParserState(
417                 OptionString optionString, CommandLineOption option, ParsedCommandLine commandLine, ParserState state) {
418             this.optionString = optionString;
419             this.option = option;
420             this.commandLine = commandLine;
421             this.state = state;
422         }
423 
424         @Override
425         public ParserState onArgument(String argument) {
426             if (!getHasArgument()) {
427                 throw new CommandLineArgumentException(
428                         String.format("Command-line option '%s' does not" + " take an argument.", optionString));
429             }
430             if (argument.length() == 0) {
431                 throw new CommandLineArgumentException(String.format(
432                         "An empty argument was provided" + " for command-line option '%s'.", optionString));
433             }
434             values.add(argument);
435             return onComplete();
436         }
437 
438         @Override
439         public ParserState onStartNextArg() {
440             if (option.getAllowsArguments() && values.isEmpty()) {
441                 return new MissingOptionArgState(this);
442             }
443             return onComplete();
444         }
445 
446         @Override
447         public boolean getHasArgument() {
448             return option.getAllowsArguments();
449         }
450 
451         @Override
452         public ParserState onComplete() {
453             if (getHasArgument() && values.isEmpty()) {
454                 throw new CommandLineArgumentException(
455                         String.format("No argument was provided" + " for command-line option '%s'.", optionString));
456             }
457 
458             ParsedCommandLineOption parsedOption = commandLine.addOption(optionString.option, option);
459             if (values.size() + parsedOption.getValues().size() > 1 && !option.getAllowsMultipleArguments()) {
460                 throw new CommandLineArgumentException(String.format(
461                         "Multiple arguments were provided" + " for command-line option '%s'.", optionString));
462             }
463             for (String value : values) {
464                 parsedOption.addArgument(value);
465             }
466             if (option.getDeprecationWarning() != null) {
467                 deprecationPrinter.println(
468                         "The " + optionString + " option is deprecated - " + option.getDeprecationWarning());
469             }
470             if (option.getSubcommand() != null) {
471                 return state.onNonOption(option.getSubcommand());
472             }
473 
474             return state;
475         }
476     }
477 
478     private static final class UnknownOptionParserState extends OptionParserState {
479         private final ParserState state;
480 
481         private final String arg;
482 
483         private final ParsedCommandLine commandLine;
484 
485         private UnknownOptionParserState(String arg, ParsedCommandLine commandLine, ParserState state) {
486             this.arg = arg;
487             this.commandLine = commandLine;
488             this.state = state;
489         }
490 
491         @Override
492         public boolean getHasArgument() {
493             return true;
494         }
495 
496         @Override
497         public ParserState onStartNextArg() {
498             return onComplete();
499         }
500 
501         @Override
502         public ParserState onArgument(String argument) {
503             return onComplete();
504         }
505 
506         @Override
507         public ParserState onComplete() {
508             commandLine.addExtraValue(arg);
509             return state;
510         }
511     }
512 
513     private static final class OptionComparator implements Comparator<CommandLineOption> {
514         public int compare(CommandLineOption option1, CommandLineOption option2) {
515             String min1 = Collections.min(option1.getOptions(), new OptionStringComparator());
516             String min2 = Collections.min(option2.getOptions(), new OptionStringComparator());
517             return new CaseInsensitiveStringComparator().compare(min1, min2);
518         }
519     }
520 
521     private static final class CaseInsensitiveStringComparator implements Comparator<String> {
522         public int compare(String option1, String option2) {
523             int diff = option1.compareToIgnoreCase(option2);
524             if (diff != 0) {
525                 return diff;
526             }
527             return option1.compareTo(option2);
528         }
529     }
530 
531     private static final class OptionStringComparator implements Comparator<String> {
532         public int compare(String option1, String option2) {
533             boolean short1 = option1.length() == 1;
534             boolean short2 = option2.length() == 1;
535             if (short1 && !short2) {
536                 return -1;
537             }
538             if (!short1 && short2) {
539                 return 1;
540             }
541             return new CaseInsensitiveStringComparator().compare(option1, option2);
542         }
543     }
544 }