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