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 }