1 /// Utilities for creating help text. 2 module jaster.cli.helptext; 3 4 private 5 { 6 import std.typecons : Flag; 7 import jaster.cli.udas; 8 } 9 10 /// A flag that should be used by content classes that have the ability to auto add argument dashes to arg names. 11 /// (e.g. "-" and "--") 12 alias AutoAddArgDashes = Flag!"addArgDashes"; 13 14 alias ArgIsOptional = Flag!"isOptional"; 15 16 /++ 17 + The interface for any class that can be used to generate content inside of a help 18 + text section. 19 + ++/ 20 interface IHelpSectionContent 21 { 22 /++ 23 + Generates a string to display inside of a help text section. 24 + 25 + Params: 26 + options = Options regarding how the text should be formatted. 27 + Please try to match these options as closely as possible. 28 + 29 + Returns: 30 + The generated content string. 31 + ++/ 32 string getContent(const HelpSectionOptions options); 33 34 // Utility functions 35 protected final 36 { 37 string lineWrap(const HelpSectionOptions options, const(char)[] value) 38 { 39 import jaster.cli.text : lineWrap, LineWrapOptions; 40 41 return value.lineWrap(LineWrapOptions(options.lineCharLimit, options.linePrefix)); 42 } 43 } 44 } 45 46 /++ 47 + A help text section. 48 + 49 + A section is basically something like "Description:", or "Arguments:", etc. 50 + 51 + Notes: 52 + Instances of this struct aren't ever really supposed to be kept around outside of `HelpTextBuilderTechnical`, so it's 53 + non-copyable. 54 + ++/ 55 struct HelpSection 56 { 57 /// The name of the section. 58 string name; 59 60 /// The content of this section. 61 IHelpSectionContent[] content; 62 63 /// The formatting options for this section. 64 HelpSectionOptions options; 65 66 /++ 67 + Adds a new piece of content to this section. 68 + 69 + Params: 70 + content = The content to add. 71 + 72 + Returns: 73 + `this` 74 + ++/ 75 ref HelpSection addContent(IHelpSectionContent content) return 76 { 77 assert(content !is null); 78 this.content ~= content; 79 80 return this; 81 } 82 83 @disable this(this){} 84 } 85 86 /++ 87 + Options on how the text of a section is formatted. 88 + 89 + Notes: 90 + It is up to the individual `IHelpSectionContent` implementors to follow these options. 91 + ++/ 92 struct HelpSectionOptions 93 { 94 /// The prefix to apply to every new line of text inside the section. 95 string linePrefix; 96 97 /// How many chars there are per line. This should be seen as a hard limit. 98 size_t lineCharLimit = 120; 99 } 100 101 /++ 102 + A class used to create help text, in an object oriented fashion. 103 + 104 + Technical_Versus_Simple: 105 + The Technical version of this class is meant to give the user full control of what's generated. 106 + 107 + The Simple version (and any other versions the user may create) are meant to have more of a scope/predefined layout, 108 + and so are simpler to use. 109 + 110 + Isnt_This_Overcomplicated?: 111 + Kind of... 112 + 113 + A goal of this library is to make it's foundational parts (such as this class, and the `ArgBinder` stuff) reusable on their own, 114 + so even if the user doesn't like how I've designed the core part of the library (Everything in `jaster.cli.core`, and the UDAs relating to it) 115 + they are given a small foundation to work off of to create their own version. 116 + 117 + So I kind of need this to be a bit more complicated than it should be, so it's easy for me to provide built-in functionality that can be used 118 + or not used as wished by the user, while also allowing the user to create their own. 119 + ++/ 120 final class HelpTextBuilderTechnical 121 { 122 /// The default options for a section. 123 static const DEFAULT_SECTION_OPTIONS = HelpSectionOptions(" ", 120); 124 125 private 126 { 127 string[] _usages; 128 HelpSection[] _sections; 129 HelpSectionOptions _sectionOptions = DEFAULT_SECTION_OPTIONS; 130 } 131 132 public final 133 { 134 /// Adds a new usage. 135 void addUsage(string usageText) 136 { 137 this._usages ~= usageText; 138 } 139 140 /++ 141 + Adds a new section. 142 + 143 + Params: 144 + sectionName = The name of the section. 145 + 146 + Returns: 147 + A reference to the section, so that the `addContent` function can be called. 148 + ++/ 149 ref HelpSection addSection(string sectionName) 150 { 151 this._sections.length += 1; 152 this._sections[$-1].name = sectionName; 153 this._sections[$-1].options = this._sectionOptions; 154 155 return this._sections[$-1]; 156 } 157 158 /// 159 ref HelpSection getOrAddSection(string sectionName) 160 { 161 // If this is too slow then we can move to an AA 162 // (p.s. std.algorithm doesn't see _sections as an input range, even if I import std.range for the array primitives) 163 foreach(ref section; this._sections) 164 { 165 if(section.name == sectionName) 166 return section; 167 } 168 169 return this.addSection(sectionName); 170 } 171 172 /++ 173 + Modifies an existing section (by returning it by reference). 174 + 175 + Assertions: 176 + `sectionName` must exist. 177 + 178 + Params: 179 + sectionName = The name of the section to modify. 180 + 181 + Returns: 182 + A reference to the section, so it can be modified by calling code. 183 + ++/ 184 ref HelpSection modifySection(string sectionName) 185 { 186 foreach(ref section; this._sections) 187 { 188 if(section.name == sectionName) 189 return section; 190 } 191 192 assert(false, "No section called '"~sectionName~"' was found."); 193 } 194 195 /++ 196 + Generates the help text based on the given usages and sections. 197 + 198 + Notes: 199 + The result of this function aren't cached yet. 200 + 201 + Returns: 202 + The generated text. 203 + ++/ 204 override string toString() 205 { 206 import std.array : appender; 207 import std.algorithm : map, each, joiner; 208 import std.exception : assumeUnique; 209 import std.format : format; 210 211 char[] output; 212 output.reserve(4096); 213 214 // Usages 215 this._usages.map!(u => "Usage: "~u) 216 .joiner("\n") 217 .each!(u => output ~= u); 218 219 // Sections 220 foreach(ref section; this._sections) 221 { 222 if(section.content.length == 0) 223 continue; 224 225 // This could all technically be 'D-ified'/'rangeified' but I couldn't make it look nice. 226 if(output.length > 0) 227 output ~= "\n\n"; 228 229 output ~= section.name~":\n"; 230 section.content.map!(c => c.getContent(section.options)) 231 .joiner("\n") 232 .each!(c => output ~= c); 233 } 234 235 return output.assumeUnique; 236 } 237 } 238 } 239 240 /++ 241 + A simpler version of `HelpTextBuilerTechnical`, as it has a fixed layout, and handles all of the section and content generation. 242 + 243 + Description: 244 + This help text builder contains the following: 245 + 246 + * A single 'Usage' line, which is generated automatically from the rest of the given data. 247 + * A description section. 248 + * A section for positional parameters, which are given a position, description, and an optional display name. 249 + * A section for named parameters, which can have multiple names, and a description. 250 + 251 + Please see the unittest for an example of its usage and output. 252 + ++/ 253 final class HelpTextBuilderSimple 254 { 255 private 256 { 257 alias NamedArg = HelpSectionArgInfoContent.ArgInfo; 258 259 struct PositionalArg 260 { 261 size_t position; 262 HelpSectionArgInfoContent.ArgInfo info; 263 } 264 265 struct ArgGroup 266 { 267 string name; 268 string description; 269 NamedArg[] named; 270 PositionalArg[] positional; 271 272 bool isDefaultGroup() 273 { 274 return this.name is null; 275 } 276 } 277 278 struct ArgGroupOrder 279 { 280 string name; 281 int order; 282 } 283 284 string _commandName; 285 string _description; 286 ArgGroup[string] _groups; 287 ArgGroupOrder[] _groupOrders; 288 289 ref ArgGroup groupByName(string name) 290 { 291 return this._groups.require(name, () 292 { 293 this._groupOrders ~= ArgGroupOrder(name, cast(int)this._groupOrders.length); 294 return ArgGroup(name); 295 }()); 296 } 297 } 298 299 public final 300 { 301 /// 302 HelpTextBuilderSimple addNamedArg(string group, string[] names, string description, ArgIsOptional isOptional) 303 { 304 this.groupByName(group).named ~= HelpSectionArgInfoContent.ArgInfo(names, description, isOptional); 305 return this; 306 } 307 308 /// 309 HelpTextBuilderSimple addNamedArg(string group, string name, string description, ArgIsOptional isOptional) 310 { 311 return this.addNamedArg(group, [name], description, isOptional); 312 } 313 314 /// 315 HelpTextBuilderSimple addNamedArg(string[] names, string description, ArgIsOptional isOptional) 316 { 317 return this.addNamedArg(null, names, description, isOptional); 318 } 319 320 /// 321 HelpTextBuilderSimple addNamedArg(string name, string description, ArgIsOptional isOptional) 322 { 323 return this.addNamedArg(null, name, description, isOptional); 324 } 325 326 /// 327 HelpTextBuilderSimple addPositionalArg(string group, size_t position, string description, ArgIsOptional isOptional, string displayName = null) 328 { 329 import std.conv : to; 330 331 this.groupByName(group).positional ~= PositionalArg( 332 position, 333 HelpSectionArgInfoContent.ArgInfo( 334 (displayName is null) ? [] : [displayName], 335 description, 336 isOptional 337 ) 338 ); 339 340 return this; 341 } 342 343 /// 344 HelpTextBuilderSimple addPositionalArg(size_t position, string description, ArgIsOptional isOptional, string displayName = null) 345 { 346 return this.addPositionalArg(null, position, description, isOptional, displayName); 347 } 348 349 /// 350 HelpTextBuilderSimple setGroupDescription(string group, string description) 351 { 352 this.groupByName(group).description = description; 353 return this; 354 } 355 356 /// 357 HelpTextBuilderSimple setDescription(string desc) 358 { 359 this.description = desc; 360 return this; 361 } 362 363 /// 364 HelpTextBuilderSimple setCommandName(string name) 365 { 366 this.commandName = name; 367 return this; 368 } 369 370 /// 371 @property 372 ref string description() 373 { 374 return this._description; 375 } 376 377 /// 378 @property 379 ref string commandName() 380 { 381 return this._commandName; 382 } 383 384 override string toString() 385 { 386 import std.algorithm : map, joiner, sort, filter; 387 import std.array : array; 388 import std.range : tee; 389 import std.format : format; 390 import std.exception : assumeUnique; 391 392 auto builder = new HelpTextBuilderTechnical(); 393 394 char[] usageString; 395 usageString.reserve(512); 396 usageString ~= this._commandName; 397 usageString ~= ' '; 398 399 if(this.description !is null) 400 { 401 builder.addSection("Description") 402 .addContent(new HelpSectionTextContent(this._description)); 403 } 404 405 void writePositionalArgs(ref HelpSection section, PositionalArg[] args) 406 { 407 section.addContent(new HelpSectionArgInfoContent( 408 args.tee!((p) 409 { 410 // Using git as a precedant for the angle brackets. 411 auto name = "%s".format(p.info.names.joiner("/")); 412 usageString ~= (p.info.isOptional) 413 ? "["~name~"]" 414 : "<"~name~">"; 415 usageString ~= ' '; 416 }) 417 .map!(p => p.info) 418 .array, 419 AutoAddArgDashes.no 420 ) 421 ); 422 } 423 424 void writeNamedArgs(Range)(ref HelpSection section, Range args) 425 { 426 if(args.empty) 427 return; 428 429 section.addContent(new HelpSectionArgInfoContent( 430 args.tee!((a) 431 { 432 auto name = "%s".format( 433 a.names 434 .map!(n => (n.length == 1) ? "-"~n : "--"~n) 435 .joiner("|") 436 ); 437 438 usageString ~= (a.isOptional) 439 ? "["~name~"]" 440 : name; 441 usageString ~= ' '; 442 }) 443 .array, 444 AutoAddArgDashes.yes 445 ) 446 ); 447 } 448 449 ref HelpSection getGroupSection(ArgGroup group) 450 { 451 scope section = &builder.getOrAddSection(group.name); 452 if(section.content.length == 0 && group.description !is null) 453 { 454 // Section was just made, so add in the description. 455 section.addContent(new HelpSectionTextContent(group.description~"\n")); 456 } 457 458 return *section; 459 } 460 461 // Not overly efficient, but keep in mind in most programs this function will only ever be called once per run (if even that). 462 // The speed is fast enough not to be annoying either. 463 // O(3n) + whatever .sort is. 464 // 465 // The reason we're doing it this way is to ensure everything is shown in this order: 466 // Positional args 467 // Required named args 468 // Optional named args 469 // 470 // Otherwise you get a mess like: Usage: tool.exe complex <file> --iterations|-i [--verbose|-v] <output> --config|-c 471 this._groupOrders.sort!"a.order < b.order"(); 472 auto groupsInOrder = this._groupOrders.map!(go => this._groups[go.name]); 473 474 // Pre-make certain sections 475 builder.addSection("Positional Args"); 476 builder.addSection("Named Args"); 477 478 // Pass #1: Write positional args first, since that puts the usage string in the right order. 479 foreach(group; groupsInOrder) 480 { 481 if(group.positional.length == 0) 482 continue; 483 484 scope section = (group.isDefaultGroup) ? &builder.getOrAddSection("Positional Args") : &getGroupSection(group); 485 writePositionalArgs(*section, group.positional); 486 } 487 488 // Pass #2: Write required named args. 489 foreach(group; groupsInOrder) 490 { 491 if(group.named.length == 0) 492 continue; 493 494 scope section = (group.isDefaultGroup) ? &builder.getOrAddSection("Named Args") : &getGroupSection(group); 495 writeNamedArgs(*section, group.named.filter!(arg => !arg.isOptional)); 496 } 497 498 // Pass #3: Write optional named args. 499 foreach(group; groupsInOrder) 500 { 501 if(group.named.length == 0) 502 continue; 503 504 scope section = (group.isDefaultGroup) ? &builder.getOrAddSection("Named Args") : &getGroupSection(group); 505 writeNamedArgs(*section, group.named.filter!(arg => arg.isOptional)); 506 } 507 508 builder.addUsage(usageString.assumeUnique); 509 return builder.toString(); 510 } 511 } 512 } 513 /// 514 unittest 515 { 516 auto builder = new HelpTextBuilderSimple(); 517 518 builder.addPositionalArg(0, "The input file.", ArgIsOptional.no, "InputFile") 519 .addPositionalArg(1, "The output file.", ArgIsOptional.no, "OutputFile") 520 .addPositionalArg("Utility", 2, "How much to compress the file.", ArgIsOptional.no, "CompressionLevel") 521 .addNamedArg(["v","verbose"], "Verbose output", ArgIsOptional.yes) 522 .addNamedArg("Utility", "encoding", "Sets the encoding to use.", ArgIsOptional.yes) 523 .setCommandName("MyCommand") 524 .setDescription("This is a command that transforms the InputFile into an OutputFile") 525 .setGroupDescription("Utility", "Utility arguments used to modify the output."); 526 527 assert(builder.toString() == 528 "Usage: MyCommand <InputFile> <OutputFile> <CompressionLevel> [-v|--verbose] [--encoding] \n" 529 ~"\n" 530 ~"Description:\n" 531 ~" This is a command that transforms the InputFile into an OutputFile\n" 532 ~"\n" 533 ~"Positional Args:\n" 534 ~" InputFile - The input file.\n" 535 ~" OutputFile - The output file.\n" 536 ~"\n" 537 ~"Named Args:\n" 538 ~" -v,--verbose - Verbose output\n" 539 ~"\n" 540 ~"Utility:\n" 541 ~" Utility arguments used to modify the output.\n" 542 ~"\n" 543 ~" CompressionLevel - How much to compress the file.\n" 544 ~" --encoding - Sets the encoding to use.", 545 546 "\n"~builder.toString() 547 ); 548 } 549 550 /+ BUILT IN SECTION CONTENT +/ 551 552 /++ 553 + A simple content class the simply displays a given string. 554 + 555 + Notes: 556 + This class is fully compliant with the `HelpSectionOptions`. 557 + ++/ 558 final class HelpSectionTextContent : IHelpSectionContent 559 { 560 /// 561 string text; 562 563 /// 564 this(string text) 565 { 566 this.text = text; 567 } 568 569 string getContent(const HelpSectionOptions options) 570 { 571 return this.lineWrap(options, this.text); 572 } 573 } 574 /// 575 unittest 576 { 577 import std.exception : assertThrown; 578 579 auto options = HelpSectionOptions("\t", 7 + 2); // '+ 2' is for the prefix + ending new line. 7 is the wanted char limit. 580 auto content = new HelpSectionTextContent("Hey Hip Lell Loll"); 581 assert(content.getContent(options) == 582 "\tHey Hip\n" 583 ~"\tLell Lo\n" 584 ~"\tll", 585 586 "\n"~content.getContent(options) 587 ); 588 589 options.lineCharLimit = 200; 590 assert(content.getContent(options) == "\tHey Hip Lell Loll"); 591 592 options.lineCharLimit = 2; // Enough for the prefix and ending new line, but not enough for any piece of text. 593 assertThrown(content.getContent(options)); 594 595 options.lineCharLimit = 3; // Useable, but not readable. 596 assert(content.getContent(options) == 597 "\tH\n" 598 ~"\te\n" 599 ~"\ty\n" 600 601 ~"\tH\n" 602 ~"\ti\n" 603 ~"\tp\n" 604 605 ~"\tL\n" 606 ~"\te\n" 607 ~"\tl\n" 608 ~"\tl\n" 609 610 ~"\tL\n" 611 ~"\to\n" 612 ~"\tl\n" 613 ~"\tl" 614 ); 615 } 616 617 /++ 618 + A content class for displaying information about a command line argument. 619 + 620 + Notes: 621 + Please see this class' unittest to see an example of it's output. 622 + ++/ 623 final class HelpSectionArgInfoContent : IHelpSectionContent 624 { 625 /// The divisor used to determine how many characters to use for the arg's name(s) 626 enum NAME_CHAR_LIMIT_DIVIDER = 4; 627 628 /// The string used to split an arg's name(s) from its description. 629 const MIDDLE_AFFIX = " - "; 630 631 /// The information about the arg. 632 struct ArgInfo 633 { 634 /// The different names that this arg can be used with (e.g 'v', 'verbose'). 635 string[] names; 636 637 /// The description of the argument. 638 string description; 639 640 /// Whether the arg is optional or not. 641 ArgIsOptional isOptional; 642 } 643 644 /// All registered args. 645 ArgInfo[] args; 646 647 /// Whether to add the dash prefix to the args' name(s). e.g. "--option" vs "option" 648 AutoAddArgDashes addDashes; 649 650 /// 651 this(ArgInfo[] args, AutoAddArgDashes addDashes) 652 { 653 this.args = args; 654 this.addDashes = addDashes; 655 } 656 657 string getContent(const HelpSectionOptions options) 658 { 659 import std.array : array; 660 import std.algorithm : map, reduce, filter, count, max, splitter, substitute; 661 import std.conv : to; 662 import std.exception : assumeUnique; 663 import std.utf : byChar; 664 665 // Calculate some variables. 666 const USEABLE_CHARS = (options.lineCharLimit - options.linePrefix.length) - MIDDLE_AFFIX.length; // How many chars in total we can use. 667 const NAME_CHARS = USEABLE_CHARS / NAME_CHAR_LIMIT_DIVIDER; // How many chars per line we can use for the names. 668 const DESCRIPTION_CHARS = USEABLE_CHARS - NAME_CHARS; // How many chars per line we can use for the description. 669 const DESCRIPTION_START = options.linePrefix.length + NAME_CHARS + MIDDLE_AFFIX.length; // How many chars in that the description starts. 670 671 // Creating the options and padding for the names and description. 672 HelpSectionOptions nameOptions; 673 nameOptions.linePrefix = options.linePrefix; 674 nameOptions.lineCharLimit = NAME_CHARS + options.linePrefix.length; 675 676 auto padding = new char[DESCRIPTION_START]; 677 padding[] = ' '; 678 679 HelpSectionOptions descriptionOptions; 680 descriptionOptions.linePrefix = padding.assumeUnique; 681 descriptionOptions.lineCharLimit = DESCRIPTION_CHARS + DESCRIPTION_START; // For the first line, the padding needs to be removed manually. 682 683 char[] output; 684 output.reserve(4096); 685 686 // Hello inefficient code, my old friend... 687 foreach(arg; this.args) 688 { 689 // Line wrap. (This line alone is like, O(3n), not even mentioning memory usage) 690 auto nameText = lineWrap( 691 nameOptions, 692 arg.names.map!(n => (this.addDashes) 693 ? (n.length == 1) ? "-"~n : "--"~n 694 : n 695 ) 696 .filter!(n => n.length > 0) 697 .reduce!((a, b) => a~","~b) 698 .byChar 699 .array 700 ); 701 702 auto descriptionText = lineWrap( 703 descriptionOptions, 704 arg.description 705 ); 706 707 if(descriptionText.length > descriptionOptions.linePrefix.length) 708 descriptionText = descriptionText[descriptionOptions.linePrefix.length..$]; // Remove the padding from the first line. 709 710 // Then create our output line-by-line 711 auto nameLines = nameText.splitter('\n'); 712 auto descriptionLines = descriptionText.splitter('\n'); 713 714 bool isFirstLine = true; 715 size_t nameLength = 0; 716 while(!nameLines.empty || !descriptionLines.empty) 717 { 718 if(!nameLines.empty) 719 { 720 nameLength = nameLines.front.length; // Need to keep track of this for the next two ifs. 721 722 output ~= nameLines.front; 723 nameLines.popFront(); 724 } 725 else 726 nameLength = 0; 727 728 if(isFirstLine) 729 { 730 // Push the middle affix into the middle, where it should be. 731 const ptrdiff_t missingChars = (NAME_CHARS - nameLength) + nameOptions.linePrefix.length; 732 if(missingChars > 0) 733 output ~= descriptionOptions.linePrefix[0..missingChars]; 734 735 output ~= MIDDLE_AFFIX; 736 } 737 738 if(!descriptionLines.empty) 739 { 740 auto description = descriptionLines.front; 741 if(!isFirstLine) 742 description = description[nameLength..$]; // The name might be multi-line, so we need to adjust the padding. 743 744 output ~= description; 745 descriptionLines.popFront(); 746 } 747 748 output ~= "\n"; 749 isFirstLine = false; // IMPORTANT for it to be here, please don't move it. 750 } 751 } 752 753 // Void the last new line, as it provides consistency with HelpSectionTextContent 754 if(output.length > 0 && output[$-1] == '\n') 755 output = output[0..$-1]; 756 757 // debug 758 // { 759 // char[] a; 760 // a.length = nameOptions.linePrefix.length; 761 // a[] = '1'; 762 // output ~= a; 763 764 // a.length = NAME_CHARS; 765 // a[] = '2'; 766 // output ~= a; 767 768 // a.length = MIDDLE_AFFIX.length; 769 // a[] = '3'; 770 // output ~= a; 771 772 // a.length = DESCRIPTION_CHARS; 773 // a[] = '4'; 774 // output ~= a; 775 // output ~= '\n'; 776 777 // a.length = DESCRIPTION_START; 778 // a[] = '>'; 779 // output ~= a; 780 // output ~= '\n'; 781 782 // a.length = descriptionOptions.lineCharLimit; 783 // a[] = '*'; 784 // output ~= a; 785 // output ~= '\n'; 786 787 // a.length = options.lineCharLimit; 788 // a[] = '#'; 789 // output ~= a; 790 // } 791 792 return output.assumeUnique; 793 } 794 } 795 /// 796 unittest 797 { 798 auto content = new HelpSectionArgInfoContent( 799 [ 800 HelpSectionArgInfoContent.ArgInfo(["v", "verbose"], "Display detailed information about what the program is doing."), 801 HelpSectionArgInfoContent.ArgInfo(["f", "file"], "The input file."), 802 HelpSectionArgInfoContent.ArgInfo(["super","longer","names"], "Some unusuable command with long names and a long description.") 803 ], 804 805 AutoAddArgDashes.yes 806 ); 807 auto options = HelpSectionOptions( 808 " ", 809 80 810 ); 811 812 assert(content.getContent(options) == 813 " -v,--verbose - Display detailed information about what the program is\n" 814 ~" doing.\n" 815 ~" -f,--file - The input file.\n" 816 ~" --super,--longer, - Some unusuable command with long names and a long desc\n" 817 ~" --names ription.", 818 819 "\n"~content.getContent(options) 820 ); 821 }