1 module commando;
2 
3 import std.uni;
4 import std.conv;
5 import std.path;
6 import std.range;
7 import std.regex;
8 import std.stdio;
9 import std.string;
10 import std.traits;
11 import std.variant;
12 import std.typecons;
13 import std.algorithm;
14 import std.exception;
15 
16 import core.stdc.stdlib;
17 
18 final class ArgumentParserException : Exception
19 {
20     mixin basicExceptionCtors;
21 }
22 
23 private enum hasSignature( alias fun, TRet, TArgs... )
24     =
25         isCallable!fun
26     &&  is( ReturnType!( fun ) == TRet )
27     &&  is( typeof( { fun( TArgs.init ); } ) );
28 
29 unittest
30 {
31     int add( int a, int b ) { return a + b; }
32     static real pow( real n, int exp ) { return n ^^ exp; }
33 
34     assert( hasSignature!( add, int, int, int ) );
35     assert( hasSignature!( pow, real,  real, int ) );
36 }
37 
38 private final class Option
39 {
40     private
41     {
42         alias TParser = Variant delegate( string );
43         alias TAssigner = void delegate( Variant );
44 
45         TParser parser;
46         TAssigner assigner;
47         bool _handled;
48         TypeInfo _type;
49     }
50 
51     bool handled() @property
52     {
53         return _handled;
54     }
55 
56     TypeInfo type() @property
57     {
58         return _type;
59     }
60 
61     Required required;
62     char shortName;
63     string longName;
64     string helpText;
65 
66     this( TPtr, TParse )( Required required, char shortName, string longName, string helpText, TParse parser, TPtr* pointer )
67         if( hasSignature!( parser, TPtr, string ) )
68     {
69         Variant parserWrapper( string value )
70         {
71             return Variant( parser( value ) );
72         }
73 
74         void assigner( Variant value )
75         {
76             *pointer = value.get!TPtr;
77         }
78 
79         _type = typeid( TPtr );
80 
81         this.parser = &parserWrapper;
82         this.assigner = &assigner;
83 
84         this.required = required;
85         this.shortName = shortName;
86         this.longName = longName;
87         this.helpText = helpText;
88     }
89 
90     bool hasShortName() @property
91     {
92         return this.shortName != '\0';
93     }
94 
95     Option opAssign( string value )
96     {
97         if( _handled )
98             return this;
99 
100         auto parsed = this.parser( value );
101         this.assigner( parsed );
102         _handled = true;
103 
104         return this;
105     }
106 
107     Option opAssign( bool value )
108     {
109         if( _handled )
110             return this;
111 
112         this.assigner( Variant( value ) );
113         _handled = true;
114 
115         return this;
116     }
117 }
118 
119 private alias CommandCallback = void delegate();
120 private final class Command
121 {
122     string command;
123     string helpText;
124     ArgumentSyntax syntax;
125     private CommandCallback callback;
126 
127     this( TFun )( string command, string helpText, ArgumentSyntax syntax, TFun callback )
128     {
129         void callbackWrapper()
130         {
131             static if( !is( TFun == typeof( null ) ) )
132                 callback();
133         }
134 
135         this.command = command;
136         this.helpText = helpText;
137         this.syntax = syntax;
138         this.callback = &callbackWrapper;
139     }
140 
141     void invoke()
142     {
143         this.callback();
144     }
145 }
146 
147 alias Required           = Flag!"Required";
148 alias AllowBundling      = Flag!"AllowBundling";
149 alias IgnoreUnrecognized = Flag!"IgnoreUnrecognized";
150 alias CaseSensitive      = Flag!"CaseSensitive";
151 
152 private struct ArgumentParserConfig
153 {
154     AllowBundling allowBundling;
155     IgnoreUnrecognized ignoreUnrecognizedOptions;
156     CaseSensitive caseSensitive;
157 }
158 
159 final class ArgumentSyntax
160 {
161     private Command[string] commands;
162     private Option[] options;
163 
164     ArgumentParserConfig config;
165 
166     private this()
167     {
168         config = ArgumentParserConfig(
169             AllowBundling.yes,
170             IgnoreUnrecognized.yes,
171             CaseSensitive.no
172         );
173     }
174 
175     private bool tryFind( string longName, out Option option )
176     {
177         bool caseSensitive = config.caseSensitive == CaseSensitive.yes;
178         if( !caseSensitive )
179             longName = longName.toLower;
180 
181         auto result = this.options.filter!( ( Option option ) {
182             auto name = !caseSensitive ? option.longName.toLower : option.longName;
183             return name == longName;
184         } ).array;
185 
186         if( result.length == 0 )
187             return false;
188 
189         option = result.front;
190         return true;
191     }
192 
193     private bool tryFind( char shortName, out Option option )
194     {
195         bool caseSensitive = config.caseSensitive == CaseSensitive.yes;
196         if( !caseSensitive )
197             shortName = cast(char)shortName.toLower;
198 
199         auto result = this.options.filter!( o => o.hasShortName )
200                                   .filter!( ( Option option ) {
201                                       char name = !caseSensitive ? cast(char)option.shortName.toLower : option.shortName;
202                                       return name == shortName;
203                                   } ).array;
204 
205         if( result.length == 0 )
206             return false;
207 
208         option = result.front;
209         return true;
210     }
211 
212     void option( TVal )( char shortName, string longName, TVal* value, Required required, string helpText )
213     {
214         auto _default = &( defaultParser!TVal );
215         this.option( shortName, longName, value, _default, required, helpText );
216     }
217 
218     void option( TVal )( string longName, TVal* value, Required required, string helpText )
219     {
220         auto _default = &( defaultParser!TVal );
221         this.option( '\0', longName, value, _default, required, helpText );
222     }
223 
224     void option( TVal, TFun )( string longName, TVal* value, TFun parser, Required required, string helpText )
225     {
226         this.option( '\0', longName, value, parser, required, helpText );
227     }
228 
229     void option( TVal, TFun )( char shortName, string longName, TVal* value, TFun parser, Required required, string helpText )
230         if( hasSignature!( parser, TVal, string ) )
231     {
232         if( ( shortName.isControl && shortName != '\0' ) || shortName == ' ' )
233             throw new ArgumentParserException( "Short name must be a printable character" );
234 
235         if( shortName == '?' || shortName == 'h' || shortName == 'H' )
236             throw new ArgumentParserException( "'%s' is a reserved flag".format( shortName ) );
237 
238         if( longName is null || longName.length == 0 || longName.all!( c => c == ' ' || c.isControl ) )
239             throw new ArgumentParserException( "Long name must consist of only printable characters and cannot be null" );
240 
241         if( longName.strip.toLower == "help" )
242             throw new ArgumentParserException( "'%s' is a reserved flag".format( longName ) );
243 
244         Option option;
245         if( this.tryFind( longName, option ) )
246             throw new ArgumentParserException( "An option with the name '%s' has already been defined".format( longName ) );
247 
248         if( shortName != '\0' && this.tryFind( shortName, option ) )
249             throw new ArgumentParserException( "An option with the name '%s' has already been defined".format( shortName ) );
250 
251         this.options ~= new Option( required, shortName, longName.strip, helpText, parser, value );
252     }
253 
254     void command( TFun )( string command, string helpText, TFun builder )
255         if( hasSignature!( builder, void, ArgumentSyntax ) )
256     {
257         this.command( command, null, helpText, builder );
258     }
259 
260     void command( TFun, TCallback )( string command, TCallback callback, string helpText, TFun builder )
261         if(
262                 ( hasSignature!( builder, void, ArgumentSyntax ) || is( TFun == typeof( null ) ) )
263              && ( hasSignature!( callback, void ) || is( TCallback == typeof( null ) ) )
264         )
265     {
266         if( command is null || command.length == 0 || command.all!( c => c == ' ' || c.isControl ) )
267             throw new ArgumentParserException( "Command must consist of only printable characters and cannot be null" );
268 
269         auto syntax = new ArgumentSyntax;
270         if( builder !is null )
271             builder( syntax );
272 
273         this.commands[command] = new Command( command, helpText, syntax, callback );
274     }
275 }
276 
277 final class ArgumentParser
278 {
279     private string appName;
280 
281     private this( string appName )
282     {
283         this.appName = appName;
284     }
285 
286     static void parse( TFun )( string[] args, TFun builder )
287         if( hasSignature!( builder, void, ArgumentSyntax ) )
288     {
289         auto syntax = new ArgumentSyntax();
290         builder( syntax );
291 
292         auto r = args.map!( s => s.strip );
293 
294         auto appName = r.front.baseName.stripExtension; r.popFront;
295         auto self = new ArgumentParser( appName );
296         self.parseImpl( r, syntax, [] );
297     }
298 
299     private void parseImpl( R )( R r, ArgumentSyntax syntax, string[] commandPath )
300         if( isForwardRange!R && is( ElementType!( R ) == string ) )
301     {
302         if( r.empty )
303             return;
304 
305         auto command = r.front in syntax.commands;
306 
307         if( command )
308         {
309             r.popFront;
310             (*command).syntax.config = syntax.config;
311             syntax = (*command).syntax;
312         }
313 
314         bool helpRequested = r.empty ? false : [ "-h", "-?", "--help" ].canFind( r.front.toLower );
315         if( !helpRequested && command )
316         {
317             this.parseImpl( r, syntax, commandPath ~ (*command).command );
318             (*command).invoke();
319             return;
320         }
321         else if( helpRequested )
322         {
323             if( command )
324             {
325                 auto path = ( commandPath ~ (*command).command ).join( " " );
326                 if( syntax.commands.length )
327                     stderr.writefln( "Usage: %s %s [<subcommand>] [<option>...]", this.appName, path );
328                 else
329                     stderr.writefln( "Usage: %s %s [<option>...]", this.appName, path );
330 
331                 stderr.writeln();
332             }
333             else if( syntax.commands.length == 0 )
334             {
335                 stderr.writefln( "Usage: %s [<option>...]", this.appName );
336                 stderr.writeln();
337             }
338             else
339             {
340                 stderr.writefln( "Usage: %s [<command>] [<option>...]", this.appName );
341                 stderr.writeln();
342                 stderr.writeln( "Available commands:" );
343                 stderr.writeln();
344             }
345 
346             if( command )
347             {
348                 if( syntax.commands.length )
349                 {
350                     stderr.writefln( "Available subcommands for [%s]", (*command).command );
351                     stderr.writeln();
352 
353                     foreach( name, command; syntax.commands )
354                         stderr.writefln( "    %s - %s", name, command.helpText );
355 
356                     stderr.writeln();
357                 }
358                 stderr.writefln( "Available options for [%s]", (*command).command );
359             }
360            else
361             {
362                 foreach( name, command; syntax.commands )
363                     stderr.writefln( "    %s - %s", name, command.helpText );
364 
365                 stderr.writeln();
366                 stderr.writeln( "Available options:" );
367             }
368 
369             stderr.writeln();
370             stderr.writeln( "    -h, -?, --help :: Show this help message" );
371             foreach( option; syntax.options )
372             {
373                 if( option.hasShortName )
374                     stderr.writef( "    -%s, --%s :: %s", option.shortName, option.longName, option.helpText );
375                 else
376                     stderr.writef( "    --%s :: %s", option.longName, option.helpText );
377 
378                 if( option.required == Required.yes )
379                     stderr.writeln( " [Required]" );
380                 else
381                     stderr.writeln();
382             }
383 
384             exit( int.min );
385         }
386 
387         enum longRegex    = ctRegex!( "^--(?P<flag>.*?)(?:[=:](?P<value>.+))?$", "" );
388         enum shortRegex   = ctRegex!( "^-(?P<flag>.)(?:[=:](?P<value>.+))?$", "" );
389         enum bundledRegex = ctRegex!( "^-(?P<flags>.{2,})$", "" );
390 
391         string getNext( T )( TypeInfo type, T flag )
392             if( isSomeChar!T || isSomeString!T )
393         {
394             if( r.empty )
395             {
396                 if( type != typeid( bool ) )
397                     throw new ArgumentParserException( "Option '%s' must have a value".format( flag ) );
398                 else
399                     return "true";
400             }
401 
402             auto current = r.front;
403             if( current[0] != '-' )
404             {
405                 r.popFront;
406                 return current;
407             }
408             else
409             {
410                 if( type != typeid( bool ) )
411                     throw new ArgumentParserException( "Option '%s' must have a value".format( flag ) );
412                 else
413                     return "true";
414             }
415         }
416 
417         Option findOrElse( T )( T flag )
418             if( isSomeChar!T || isSomeString!T )
419         {
420             Option result;
421             if( !syntax.tryFind( flag, result ) )
422             {
423                 if( syntax.config.ignoreUnrecognizedOptions == IgnoreUnrecognized.yes )
424                     return null;
425                 else
426                     throw new ArgumentParserException( "Unrecognized option '%s%s'".format( isSomeChar!T ? "-" : "--", flag ) );
427             }
428 
429             return result;
430         }
431 
432         while( !r.empty )
433         {
434             auto current = r.front; r.popFront;
435 
436             // Double dash by itself signals that we should stop parsing
437             if( current == "--" )
438                 break;
439 
440             if( auto match = current.matchFirst( longRegex ) )
441             {
442                 auto flag = match["flag"];
443                 auto option = findOrElse( flag );
444 
445                 if( option is null )
446                     continue;
447 
448                 auto hasValue = match["value"] !is null && match["value"].length > 0;
449                 option = hasValue ? match["value"] : getNext( option.type, flag );
450             }
451             else if( auto match = current.matchFirst( shortRegex ) )
452             {
453                 auto flag = match["flag"][0];
454                 auto option = findOrElse( flag );
455 
456                 if( option is null )
457                     continue;
458 
459                 auto hasValue = match["value"] !is null && match["value"].length > 0;
460                 option = hasValue ? match["value"] : getNext( option.type, flag );
461             }
462             else if( auto match = current.matchFirst( bundledRegex ) )
463             {
464                 foreach( flag; match["flags"] )
465                 {
466                     auto option = findOrElse( flag );
467 
468                     if( option is null )
469                         continue;
470 
471                     option = true;
472                 }
473             }
474         }
475 
476         auto notHandled = syntax.options.filter!( o => o.required == Required.yes );
477 
478         bool quit = false;
479         foreach( opt; notHandled )
480         {
481             if( opt.handled )
482                 continue;
483 
484             quit = true;
485             if( opt.hasShortName )
486                 stderr.writefln( "Missing required option -%s/--%s", opt.shortName, opt.longName );
487             else
488                 stderr.writefln( "Missing required option --%s", opt.longName );
489         }
490 
491         if( quit )
492             exit( int.min );
493     }
494 }
495 
496 private TVal defaultParser( TVal )( string value )
497     if( std.traits.isNumeric!TVal )
498 {
499     return value.to!TVal;
500 }
501 
502 private TVal defaultParser( TVal )( string value )
503     if( is( TVal == bool ) )
504 {
505     return !( [ "0", "no", "off", "false" ].canFind( value.strip.toLower ) );
506 }
507 
508 private TVal defaultParser( TVal )( string value )
509     if( is( TVal : string ) )
510 {
511     return value;
512 }
513 
514 unittest
515 {
516     struct PersonOptions
517     {
518         string firstName;
519         string lastName;
520         ubyte age;
521     }
522 
523     double test;
524     bool verbose;
525     PersonOptions person;
526     void addEmployee()
527     {
528         assert( person != PersonOptions.init );
529     }
530 
531     void testBuilder( ArgumentSyntax syntax )
532     {
533         syntax.config.caseSensitive = CaseSensitive.yes;
534         syntax.config.allowBundling = AllowBundling.no;
535         syntax.config.ignoreUnrecognizedOptions = IgnoreUnrecognized.no;
536 
537         syntax.command( "employee", "Employee operations", ( ArgumentSyntax syntax )
538         {
539             syntax.option( 't', "test", &test, Required.no, "Test option" );
540             syntax.command( "new", &addEmployee, "Add new employee", ( ArgumentSyntax syntax )
541             {
542                 syntax.option( "firstName", &person.firstName, Required.yes, "The employee's first name" );
543                 syntax.option( "lastName", &person.lastName, Required.yes, "The employee's last name" );
544                 syntax.option( 'a', "age", &person.age, Required.yes, "The employee's age" );
545             } );
546         } );
547 
548         syntax.option( 'v', "verbose", &verbose, Required.no, "Print extra information" );
549     }
550 
551     void parse( string[] args )
552     {
553         ArgumentParser.parse( args, &testBuilder );
554     }
555 
556     // $ manage -v no
557     auto args1 = [
558         "./manage.exe", // binary path. should always be first
559         "-v", "no",
560     ];
561 
562     // $ manage employee -t:123.45
563     auto args2 = [
564         "./manage.exe",
565         "employee",
566         "-t:123.45"
567     ];
568 
569     // $ manage employee new --firstName John --lastName Doe --age 35
570     auto args3 = [
571         "./manage.exe",
572         "employee",
573         "new",
574         "--firstName", "John",
575         "--lastName", "Doe",
576         "-a", "35"
577     ];
578 
579     parse( args1 );
580     parse( args2 );
581     parse( args3 );
582 
583     assert( test == 123.45 );
584     assert( verbose == false );
585     assert( person == PersonOptions( "John", "Doe", 35 ) );
586 }