1 /** 2 * Translate command-line parameters to a function signature, 3 * generating --help text automatically. 4 * 5 * License: 6 * This Source Code Form is subject to the terms of 7 * the Mozilla Public License, v. 2.0. If a copy of 8 * the MPL was not distributed with this file, You 9 * can obtain one at http://mozilla.org/MPL/2.0/. 10 * 11 * Authors: 12 * Vladimir Panteleev <vladimir@thecybershadow.net> 13 */ 14 15 module ae.utils.funopt; 16 17 import std.algorithm; 18 import std.array; 19 import std.conv; 20 import std.getopt; 21 import std.path; 22 import std.range; 23 import std.string; 24 import std.traits; 25 import std.typetuple; 26 27 import ae.utils.meta : structFields, hasAttribute, getAttribute, RangeTuple, I; 28 import ae.utils.array : split1; 29 import ae.utils.text; 30 31 private enum OptionType { switch_, option, parameter } 32 33 struct OptionImpl(OptionType type_, T_, string description_, char shorthand_, string placeholder_, string name_) 34 { 35 enum type = type_; 36 alias T = T_; 37 enum description = description_; 38 enum shorthand = shorthand_; 39 enum placeholder = placeholder_; 40 enum name = name_; 41 42 T value; 43 alias value this; 44 45 this(T value_) 46 { 47 value = value_; 48 } 49 } 50 51 /// An on/off switch (e.g. --verbose). Does not have a value, other than its presence. 52 template Switch(string description=null, char shorthand=0, string name=null) 53 { 54 alias Switch = OptionImpl!(OptionType.switch_, bool, description, shorthand, null, name); 55 } 56 57 /// An option with a value (e.g. --tries N). The default placeholder depends on the type 58 /// (N for numbers, STR for strings). 59 template Option(T, string description=null, string placeholder=null, char shorthand=0, string name=null) 60 { 61 alias Option = OptionImpl!(OptionType.option, T, description, shorthand, placeholder, name); 62 } 63 64 /// An ordered parameter. 65 template Parameter(T, string description=null, string name=null) 66 { 67 alias Parameter = OptionImpl!(OptionType.parameter, T, description, 0, null, name); 68 } 69 70 /// Specify this as the description to hide the option from --help output. 71 enum hiddenOption = "hiddenOption"; 72 73 private template OptionValueType(T) 74 { 75 static if (is(T == OptionImpl!Args, Args...)) 76 alias OptionValueType = T.T; 77 else 78 alias OptionValueType = T; 79 } 80 81 private OptionValueType!T* optionValue(T)(ref T option) 82 { 83 static if (is(T == OptionImpl!Args, Args...)) 84 return &option.value; 85 else 86 return &option; 87 } 88 89 private template isParameter(T) 90 { 91 static if (is(T == OptionImpl!Args, Args...)) 92 enum isParameter = T.type == OptionType.parameter; 93 else 94 static if (is(T == bool)) 95 enum isParameter = false; 96 else 97 enum isParameter = true; 98 } 99 100 private template isOptionArray(Param) 101 { 102 alias T = OptionValueType!Param; 103 static if (is(Unqual!T == string)) 104 enum isOptionArray = false; 105 else 106 static if (is(T U : U[])) 107 enum isOptionArray = true; 108 else 109 enum isOptionArray = false; 110 } 111 112 private template optionShorthand(T) 113 { 114 static if (is(T == OptionImpl!Args, Args...)) 115 enum optionShorthand = T.shorthand; 116 else 117 enum char optionShorthand = 0; 118 } 119 120 private template optionDescription(T) 121 { 122 static if (is(T == OptionImpl!Args, Args...)) 123 enum optionDescription = T.description; 124 else 125 enum string optionDescription = null; 126 } 127 128 private enum bool optionHasDescription(T) = !isHiddenOption!T && optionDescription!T !is null; 129 130 private template optionPlaceholder(T) 131 { 132 static if (is(T == OptionImpl!Args, Args...)) 133 { 134 static if (T.placeholder.length) 135 enum optionPlaceholder = T.placeholder; 136 else 137 enum optionPlaceholder = optionPlaceholder!(OptionValueType!T); 138 } 139 else 140 static if (isOptionArray!T) 141 enum optionPlaceholder = optionPlaceholder!(typeof(T.init[0])); 142 else 143 static if (is(T : real)) 144 enum optionPlaceholder = "N"; 145 else 146 static if (is(T == string)) 147 enum optionPlaceholder = "STR"; 148 else 149 enum optionPlaceholder = "X"; 150 } 151 152 private template optionName(T, string paramName) 153 { 154 static if (is(T == OptionImpl!Args, Args...)) 155 static if (T.name) 156 enum optionName = T.name; 157 else 158 enum optionName = paramName; 159 else 160 enum optionName = paramName; 161 } 162 163 private template isHiddenOption(T) 164 { 165 static if (is(T == OptionImpl!Args, Args...)) 166 static if (T.description is hiddenOption) 167 enum isHiddenOption = true; 168 else 169 enum isHiddenOption = false; 170 else 171 enum isHiddenOption = false; 172 } 173 174 struct FunOptConfig 175 { 176 std.getopt.config[] getoptConfig; 177 } 178 179 private template optionNames(alias FUN) 180 { 181 alias Params = ParameterTypeTuple!FUN; 182 alias parameterNames = ParameterIdentifierTuple!FUN; 183 enum optionNameAt(int i) = optionName!(Params[i], parameterNames[i]); 184 alias optionNames = staticMap!(optionNameAt, RangeTuple!(parameterNames.length)); 185 } 186 187 /// Default help text print function. 188 /// Sends the text to stderr.writeln. 189 void defaultUsageFun(string usage) 190 { 191 import std.stdio; 192 stderr.writeln(usage); 193 } 194 195 /// Parse the given arguments according to FUN's parameters, and call FUN. 196 /// Throws GetOptException on errors. 197 auto funopt(alias FUN, FunOptConfig config = FunOptConfig.init, alias usageFun = defaultUsageFun)(string[] args) 198 if (isFunction!FUN) 199 { 200 alias Params = staticMap!(Unqual, ParameterTypeTuple!FUN); 201 Params values; 202 enum names = optionNames!FUN; 203 alias defaults = ParameterDefaultValueTuple!FUN; 204 205 foreach (i, defaultValue; defaults) 206 { 207 static if (!is(defaultValue == void)) 208 { 209 //values[i] = defaultValue; 210 // https://issues.dlang.org/show_bug.cgi?id=13252 211 values[i] = cast(OptionValueType!(Params[i])) defaultValue; 212 } 213 } 214 215 enum structFields = 216 config.getoptConfig.length.iota.map!(n => "std.getopt.config config%d = std.getopt.config.%s;\n".format(n, config.getoptConfig[n])).join() ~ 217 Params.length.iota.map!(n => "string selector%d; OptionValueType!(Params[%d])* value%d;\n".format(n, n, n)).join(); 218 219 static struct GetOptArgs { mixin(structFields); } 220 GetOptArgs getOptArgs; 221 222 static string optionSelector(int i)() 223 { 224 string[] variants; 225 auto shorthand = optionShorthand!(Params[i]); 226 if (shorthand) 227 variants ~= [shorthand]; 228 enum keywords = names[i].identifierToCommandLineKeywords(); 229 variants ~= keywords; 230 return variants.join("|"); 231 } 232 233 foreach (i, ref value; values) 234 { 235 enum selector = optionSelector!i(); 236 mixin("getOptArgs.selector%d = selector;".format(i)); 237 mixin("getOptArgs.value%d = optionValue(values[%d]);".format(i, i)); 238 } 239 240 auto origArgs = args; 241 bool help; 242 243 getopt(args, 244 std.getopt.config.bundling, 245 getOptArgs.tupleof, 246 "h|help", &help, 247 ); 248 249 void printUsage() 250 { 251 usageFun(getUsage!FUN(origArgs[0])); 252 } 253 254 if (help) 255 { 256 printUsage(); 257 static if (is(ReturnType!FUN == void)) 258 return; 259 else 260 return ReturnType!FUN.init; 261 } 262 263 args = args[1..$]; 264 265 // Slurp remaining, unparsed arguments into parameter fields 266 267 foreach (i, ref value; values) 268 { 269 alias T = Params[i]; 270 static if (isParameter!T) 271 { 272 static if (is(OptionValueType!T : const(string)[])) 273 { 274 values[i] = cast(OptionValueType!T)args; 275 args = null; 276 } 277 else 278 { 279 if (args.length) 280 { 281 values[i] = to!(OptionValueType!T)(args[0]); 282 args = args[1..$]; 283 } 284 else 285 { 286 static if (is(defaults[i] == void)) 287 { 288 // If the first argument is mandatory, 289 // and no arguments were given, print usage. 290 if (origArgs.length == 1) 291 printUsage(); 292 293 enum plainName = names[i].identifierToPlainText; 294 throw new GetOptException("No " ~ plainName ~ " specified."); 295 } 296 } 297 } 298 } 299 } 300 301 if (args.length) 302 throw new GetOptException("Extra parameters specified: %(%s %)".format(args)); 303 304 return FUN(values); 305 } 306 307 unittest 308 { 309 void f1(bool verbose, Option!int tries, string filename) 310 { 311 assert(verbose); 312 assert(tries == 5); 313 assert(filename == "filename.ext"); 314 } 315 funopt!f1(["program", "--verbose", "--tries", "5", "filename.ext"]); 316 317 void f2(string a, Parameter!string b, string[] rest) 318 { 319 assert(a == "a"); 320 assert(b == "b"); 321 assert(rest == ["c", "d"]); 322 } 323 funopt!f2(["program", "a", "b", "c", "d"]); 324 325 void f3(Option!(string[], null, "DIR", 'x') excludeDir) 326 { 327 assert(excludeDir == ["a", "b", "c"]); 328 } 329 funopt!f3(["program", "--excludedir", "a", "--exclude-dir", "b", "-x", "c"]); 330 331 void f4(Option!string outputFile = "output.txt", string inputFile = "input.txt", string[] dataFiles = null) 332 { 333 assert(inputFile == "input.txt"); 334 assert(outputFile == "output.txt"); 335 assert(dataFiles == []); 336 } 337 funopt!f4(["program"]); 338 339 void f5(string input = null) 340 { 341 assert(input is null); 342 } 343 funopt!f5(["program"]); 344 } 345 346 // *************************************************************************** 347 348 private string canonicalizeCommandLineArgument(string s) { return s.replace("-", ""); } 349 private string canonicalizeIdentifier(string s) { return s.chomp("_").toLower(); } 350 private string identifierToCommandLineKeyword(string s) { return s.chomp("_").splitByCamelCase.join("-").toLower(); } 351 private string identifierToCommandLineParam (string s) { return s.chomp("_").splitByCamelCase.join("-").toUpper(); } 352 private string identifierToPlainText (string s) { return s.chomp("_").splitByCamelCase.join(" ").toLower(); } 353 private string[] identifierToCommandLineKeywords(string s) { auto words = s.chomp("_").splitByCamelCase(); return [words.join().toLower()] ~ (words.length > 1 ? [words.join("-").toLower()] : []); } /// for getopt 354 355 private string getProgramName(string program) 356 { 357 auto programName = program.baseName(); 358 version(Windows) 359 { 360 programName = programName.toLower(); 361 if (programName.extension == ".exe") 362 programName = programName.stripExtension(); 363 } 364 365 return programName; 366 } 367 368 private string escapeFmt(string s) { return s.replace("%", "%%"); } 369 370 string getUsage(alias FUN)(string program) 371 { 372 auto programName = getProgramName(program); 373 enum formatString = getUsageFormatString!FUN(); 374 return formatString.format(programName); 375 } 376 377 string getUsageFormatString(alias FUN)() 378 { 379 alias ParameterTypeTuple!FUN Params; 380 enum names = [optionNames!FUN]; 381 alias defaults = ParameterDefaultValueTuple!FUN; 382 383 string result = "Usage: %1$s"; 384 enum inSynopsis(Param) = isParameter!Param || !optionHasDescription!Param; 385 enum haveOmittedOptions = !allSatisfy!(inSynopsis, Params); 386 static if (haveOmittedOptions) 387 result ~= " [OPTION]..."; 388 389 string getSwitchText(int i)() 390 { 391 alias Param = Params[i]; 392 static if (isParameter!Param) 393 return names[i].identifierToCommandLineParam(); 394 else 395 { 396 string switchText = "--" ~ names[i].identifierToCommandLineKeyword(); 397 static if (is(Param == OptionImpl!Args, Args...)) 398 static if (Param.type == OptionType.option) 399 switchText ~= (optionPlaceholder!Param.canFind('=') ? ' ' : '=') ~ optionPlaceholder!Param; 400 return switchText; 401 } 402 } 403 404 string optionalEnd; 405 void flushOptional() { result ~= optionalEnd; optionalEnd = null; } 406 foreach (i, Param; Params) 407 static if (!isHiddenOption!Param && inSynopsis!Param) 408 { 409 static if (isParameter!Param) 410 { 411 result ~= " "; 412 static if (!is(defaults[i] == void)) 413 { 414 result ~= "["; 415 optionalEnd ~= "]"; 416 } 417 result ~= names[i].identifierToCommandLineParam(); 418 } 419 else 420 { 421 flushOptional(); 422 result ~= " [" ~ getSwitchText!i().escapeFmt() ~ "]"; 423 } 424 static if (isOptionArray!Param) 425 result ~= "..."; 426 } 427 flushOptional(); 428 429 result ~= "\n"; 430 static if (hasAttribute!(string, FUN)) 431 { 432 enum description = getAttribute!(string, FUN); 433 result ~= "\n" ~ description ~ "\n"; 434 } 435 436 enum haveDescriptions = anySatisfy!(optionHasDescription, Params); 437 static if (haveDescriptions) 438 { 439 enum haveShorthands = anySatisfy!(optionShorthand, Params); 440 string[Params.length] selectors; 441 size_t longestSelector; 442 443 foreach (i, Param; Params) 444 static if (optionHasDescription!Param) 445 { 446 string switchText = getSwitchText!i(); 447 if (haveShorthands) 448 { 449 auto c = optionShorthand!Param; 450 if (c) 451 selectors[i] = "-%s, %s".format(c, switchText); 452 else 453 selectors[i] = " %s".format(switchText); 454 } 455 else 456 selectors[i] = switchText; 457 longestSelector = max(longestSelector, selectors[i].length); 458 } 459 460 result ~= "\nOptions:\n"; 461 foreach (i, Param; Params) 462 static if (optionHasDescription!Param) 463 result ~= optionWrap(optionDescription!Param.escapeFmt(), selectors[i], longestSelector); 464 } 465 466 return result; 467 } 468 469 string optionWrap(string text, string firstIndent, size_t indentWidth) 470 { 471 enum width = 79; 472 auto padding = " ".replicate(2 + indentWidth + 2); 473 text = text.findSplit("\n\n")[0]; 474 auto paragraphs = text.split1("\n"); 475 auto result = verbatimWrap( 476 paragraphs[0], 477 width, 478 " %-*s ".format(indentWidth, firstIndent), 479 padding 480 ); 481 result ~= paragraphs[1..$].map!(p => verbatimWrap(p, width, padding, padding)).join(); 482 return result; 483 } 484 485 unittest 486 { 487 void f1( 488 Switch!("Enable verbose logging", 'v') verbose, 489 Option!(int, "Number of tries") tries, 490 Option!(int, "Seconds to\nwait each try", "SECS", 0, "timeout") t, 491 in string filename, 492 string output = "default", 493 string[] extraFiles = null 494 ) 495 {} 496 497 auto usage = getUsage!f1("program"); 498 assert(usage == 499 "Usage: program [OPTION]... FILENAME [OUTPUT [EXTRA-FILES...]] 500 501 Options: 502 -v, --verbose Enable verbose logging 503 --tries=N Number of tries 504 --timeout=SECS Seconds to 505 wait each try 506 ", usage); 507 508 void f2( 509 bool verbose, 510 Option!(string[]) extraFile, 511 string filename, 512 string output = "default", 513 ) 514 {} 515 516 usage = getUsage!f2("program"); 517 assert(usage == 518 "Usage: program [--verbose] [--extra-file=STR]... FILENAME [OUTPUT] 519 ", usage); 520 521 void f3( 522 Parameter!(string[]) args = null, 523 ) 524 {} 525 526 usage = getUsage!f3("program"); 527 assert(usage == 528 "Usage: program [ARGS...] 529 ", usage); 530 531 void f4( 532 Parameter!(string[], "The program arguments.") args = null, 533 ) 534 {} 535 536 usage = getUsage!f4("program"); 537 assert(usage == 538 "Usage: program [ARGS...] 539 540 Options: 541 ARGS The program arguments. 542 ", usage); 543 544 void f5( 545 Option!(string[], "Features to disable.") without = null, 546 ) 547 {} 548 549 usage = getUsage!f5("program"); 550 assert(usage == 551 "Usage: program [OPTION]... 552 553 Options: 554 --without=STR Features to disable. 555 ", usage); 556 557 // If all options are on the command line, don't add "[OPTION]..." 558 void f6( 559 bool verbose, 560 Parameter!(string[], "Files to transmogrify.") files = null, 561 ) 562 {} 563 564 usage = getUsage!f6("program"); 565 assert(usage == 566 "Usage: program [--verbose] [FILES...] 567 568 Options: 569 FILES Files to transmogrify. 570 ", usage); 571 572 // Ensure % characters work as expected. 573 void f7( 574 Parameter!(int, "How much power % to use.") powerPct, 575 ) 576 {} 577 578 usage = getUsage!f7("program"); 579 assert(usage == 580 "Usage: program POWER-PCT 581 582 Options: 583 POWER-PCT How much power % to use. 584 ", usage); 585 586 // Test program descriptions 587 @(`Refrobnicates the transmogrifier.`) 588 void f8(Switch!"Be verbose." verbose){} 589 590 usage = getUsage!f8("program"); 591 assert(usage == 592 "Usage: program [OPTION]... 593 594 Refrobnicates the transmogrifier. 595 596 Options: 597 --verbose Be verbose. 598 ", usage); 599 } 600 601 // *************************************************************************** 602 603 /// Dispatch the command line to a type's static methods, according to the 604 /// first parameter on the given command line (the "action"). 605 /// String UDAs are used as usage documentation for generating --help output 606 /// (or when no action is specified). 607 auto funoptDispatch(alias Actions, FunOptConfig config = FunOptConfig.init, alias usageFun = defaultUsageFun)(string[] args) 608 { 609 string program = args[0]; 610 611 auto fun(string action, string[] actionArguments = []) 612 { 613 action = action.canonicalizeCommandLineArgument(); 614 615 foreach (m; __traits(allMembers, Actions)) 616 static if (is(typeof(hasAttribute!(string, __traits(getMember, Actions, m))))) 617 { 618 alias member = I!(__traits(getMember, Actions, m)); 619 enum name = m.canonicalizeIdentifier(); 620 if (name == action) 621 { 622 auto args = [getProgramName(program) ~ " " ~ action] ~ actionArguments; 623 static if (is(member == struct)) 624 return funoptDispatch!(member, config, usageFun)(args); 625 else 626 return funopt!(member, config, usageFun)(args); 627 } 628 } 629 630 throw new GetOptException("Unknown action: " ~ action); 631 } 632 633 static void myUsageFun(string usage) { usageFun(usage ~ funoptDispatchUsage!Actions()); } 634 635 const FunOptConfig myConfig = (){ 636 auto c = config; 637 c.getoptConfig ~= std.getopt.config.stopOnFirstNonOption; 638 return c; 639 }(); 640 return funopt!(fun, myConfig, myUsageFun)(args); 641 } 642 643 string funoptDispatchUsage(alias Actions)() 644 { 645 string result = "\nActions:\n"; 646 647 size_t longestAction = 0; 648 foreach (m; __traits(allMembers, Actions)) 649 static if (is(typeof(hasAttribute!(string, __traits(getMember, Actions, m))))) 650 static if (hasAttribute!(string, __traits(getMember, Actions, m))) 651 { 652 enum length = m.identifierToCommandLineKeyword().length; 653 longestAction = max(longestAction, length); 654 } 655 656 foreach (m; __traits(allMembers, Actions)) 657 static if (is(typeof(hasAttribute!(string, __traits(getMember, Actions, m))))) 658 static if (hasAttribute!(string, __traits(getMember, Actions, m))) 659 { 660 enum name = m.identifierToCommandLineKeyword(); 661 //__traits(comment, __traits(getMember, Actions, m)) // https://github.com/D-Programming-Language/dmd/pull/3531 662 result ~= optionWrap(getAttribute!(string, __traits(getMember, Actions, m)), name, longestAction); 663 } 664 665 return result; 666 } 667 668 unittest 669 { 670 struct Actions 671 { 672 @(`Perform action f1`) 673 static void f1(bool verbose) {} 674 675 @(`Perform complicated action f2 676 677 This action is complicated because of reasons.`) 678 static void f2() {} 679 680 @(`An action sub-group`) 681 struct fooBar 682 { 683 @(`Create a new foobar`) 684 static void new_() {} 685 } 686 } 687 688 funoptDispatch!Actions(["program", "f1", "--verbose"]); 689 690 assert(funoptDispatchUsage!Actions() == " 691 Actions: 692 f1 Perform action f1 693 f2 Perform complicated action f2 694 foo-bar An action sub-group 695 "); 696 697 funoptDispatch!Actions(["program", "foo-bar", "new"]); 698 699 assert(funoptDispatchUsage!(Actions.fooBar)() == " 700 Actions: 701 new Create a new foobar 702 "); 703 704 static string usage; 705 static void usageFun(string _usage) { usage = _usage; } 706 funoptDispatch!(Actions, FunOptConfig.init, usageFun)(["unittest", "f1", "--help"]); 707 assert(usage == "Usage: unittest f1 [--verbose] 708 709 Perform action f1 710 ", usage); 711 712 funoptDispatch!(Actions, FunOptConfig.init, usageFun)(["unittest", "f2", "--help"]); 713 assert(usage == "Usage: unittest f2 714 715 Perform complicated action f2 716 717 This action is complicated because of reasons. 718 ", usage); 719 }