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