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