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 scope section = (group.isDefaultGroup) ? &builder.getOrAddSection("Positional Args") : &getGroupSection(group); 482 writePositionalArgs(*section, group.positional); 483 } 484 485 // Pass #2: Write required named args. 486 foreach(group; groupsInOrder) 487 { 488 scope section = (group.isDefaultGroup) ? &builder.getOrAddSection("Named Args") : &getGroupSection(group); 489 writeNamedArgs(*section, group.named.filter!(arg => !arg.isOptional)); 490 } 491 492 // Pass #3: Write optional named args. 493 foreach(group; groupsInOrder) 494 { 495 scope section = (group.isDefaultGroup) ? &builder.getOrAddSection("Named Args") : &getGroupSection(group); 496 writeNamedArgs(*section, group.named.filter!(arg => arg.isOptional)); 497 } 498 499 builder.addUsage(usageString.assumeUnique); 500 return builder.toString(); 501 } 502 } 503 } 504 /// 505 unittest 506 { 507 auto builder = new HelpTextBuilderSimple(); 508 509 builder.addPositionalArg(0, "The input file.", ArgIsOptional.no, "InputFile") 510 .addPositionalArg(1, "The output file.", ArgIsOptional.no, "OutputFile") 511 .addPositionalArg("Utility", 2, "How much to compress the file.", ArgIsOptional.no, "CompressionLevel") 512 .addNamedArg(["v","verbose"], "Verbose output", ArgIsOptional.yes) 513 .addNamedArg("Utility", "encoding", "Sets the encoding to use.", ArgIsOptional.yes) 514 .setCommandName("MyCommand") 515 .setDescription("This is a command that transforms the InputFile into an OutputFile") 516 .setGroupDescription("Utility", "Utility arguments used to modify the output."); 517 518 assert(builder.toString() == 519 "Usage: MyCommand <InputFile> <OutputFile> <CompressionLevel> [-v|--verbose] [--encoding] \n" 520 ~"\n" 521 ~"Description:\n" 522 ~" This is a command that transforms the InputFile into an OutputFile\n" 523 ~"\n" 524 ~"Positional Args:\n" 525 ~" InputFile - The input file.\n" 526 ~" OutputFile - The output file.\n" 527 ~"\n" 528 ~"Named Args:\n" 529 ~" -v,--verbose - Verbose output\n" 530 ~"\n" 531 ~"Utility:\n" 532 ~" Utility arguments used to modify the output.\n" 533 ~"\n" 534 ~" CompressionLevel - How much to compress the file.\n" 535 ~" --encoding - Sets the encoding to use.", 536 537 "\n"~builder.toString() 538 ); 539 } 540 541 /+ BUILT IN SECTION CONTENT +/ 542 543 /++ 544 + A simple content class the simply displays a given string. 545 + 546 + Notes: 547 + This class is fully compliant with the `HelpSectionOptions`. 548 + ++/ 549 final class HelpSectionTextContent : IHelpSectionContent 550 { 551 /// 552 string text; 553 554 /// 555 this(string text) 556 { 557 this.text = text; 558 } 559 560 string getContent(const HelpSectionOptions options) 561 { 562 return this.lineWrap(options, this.text); 563 } 564 } 565 /// 566 unittest 567 { 568 import std.exception : assertThrown; 569 570 auto options = HelpSectionOptions("\t", 7 + 2); // '+ 2' is for the prefix + ending new line. 7 is the wanted char limit. 571 auto content = new HelpSectionTextContent("Hey Hip Lell Loll"); 572 assert(content.getContent(options) == 573 "\tHey Hip\n" 574 ~"\tLell Lo\n" 575 ~"\tll", 576 577 "\n"~content.getContent(options) 578 ); 579 580 options.lineCharLimit = 200; 581 assert(content.getContent(options) == "\tHey Hip Lell Loll"); 582 583 options.lineCharLimit = 2; // Enough for the prefix and ending new line, but not enough for any piece of text. 584 assertThrown(content.getContent(options)); 585 586 options.lineCharLimit = 3; // Useable, but not readable. 587 assert(content.getContent(options) == 588 "\tH\n" 589 ~"\te\n" 590 ~"\ty\n" 591 592 ~"\tH\n" 593 ~"\ti\n" 594 ~"\tp\n" 595 596 ~"\tL\n" 597 ~"\te\n" 598 ~"\tl\n" 599 ~"\tl\n" 600 601 ~"\tL\n" 602 ~"\to\n" 603 ~"\tl\n" 604 ~"\tl" 605 ); 606 } 607 608 /++ 609 + A content class for displaying information about a command line argument. 610 + 611 + Notes: 612 + Please see this class' unittest to see an example of it's output. 613 + ++/ 614 final class HelpSectionArgInfoContent : IHelpSectionContent 615 { 616 /// The divisor used to determine how many characters to use for the arg's name(s) 617 enum NAME_CHAR_LIMIT_DIVIDER = 4; 618 619 /// The string used to split an arg's name(s) from its description. 620 const MIDDLE_AFFIX = " - "; 621 622 /// The information about the arg. 623 struct ArgInfo 624 { 625 /// The different names that this arg can be used with (e.g 'v', 'verbose'). 626 string[] names; 627 628 /// The description of the argument. 629 string description; 630 631 /// Whether the arg is optional or not. 632 ArgIsOptional isOptional; 633 } 634 635 /// All registered args. 636 ArgInfo[] args; 637 638 /// Whether to add the dash prefix to the args' name(s). e.g. "--option" vs "option" 639 AutoAddArgDashes addDashes; 640 641 /// 642 this(ArgInfo[] args, AutoAddArgDashes addDashes) 643 { 644 this.args = args; 645 this.addDashes = addDashes; 646 } 647 648 string getContent(const HelpSectionOptions options) 649 { 650 import std.array : array; 651 import std.algorithm : map, reduce, filter, count, max, splitter, substitute; 652 import std.conv : to; 653 import std.exception : assumeUnique; 654 import std.utf : byChar; 655 656 // Calculate some variables. 657 const USEABLE_CHARS = (options.lineCharLimit - options.linePrefix.length) - MIDDLE_AFFIX.length; // How many chars in total we can use. 658 const NAME_CHARS = USEABLE_CHARS / NAME_CHAR_LIMIT_DIVIDER; // How many chars per line we can use for the names. 659 const DESCRIPTION_CHARS = USEABLE_CHARS - NAME_CHARS; // How many chars per line we can use for the description. 660 const DESCRIPTION_START = options.linePrefix.length + NAME_CHARS + MIDDLE_AFFIX.length; // How many chars in that the description starts. 661 662 // Creating the options and padding for the names and description. 663 HelpSectionOptions nameOptions; 664 nameOptions.linePrefix = options.linePrefix; 665 nameOptions.lineCharLimit = NAME_CHARS + options.linePrefix.length; 666 667 auto padding = new char[DESCRIPTION_START]; 668 padding[] = ' '; 669 670 HelpSectionOptions descriptionOptions; 671 descriptionOptions.linePrefix = padding.assumeUnique; 672 descriptionOptions.lineCharLimit = DESCRIPTION_CHARS + DESCRIPTION_START; // For the first line, the padding needs to be removed manually. 673 674 char[] output; 675 output.reserve(4096); 676 677 // Hello inefficient code, my old friend... 678 foreach(arg; this.args) 679 { 680 // Line wrap. (This line alone is like, O(3n), not even mentioning memory usage) 681 auto nameText = lineWrap( 682 nameOptions, 683 arg.names.map!(n => (this.addDashes) 684 ? (n.length == 1) ? "-"~n : "--"~n 685 : n 686 ) 687 .filter!(n => n.length > 0) 688 .reduce!((a, b) => a~","~b) 689 .byChar 690 .array 691 ); 692 693 auto descriptionText = lineWrap( 694 descriptionOptions, 695 arg.description 696 ); 697 698 if(descriptionText.length > descriptionOptions.linePrefix.length) 699 descriptionText = descriptionText[descriptionOptions.linePrefix.length..$]; // Remove the padding from the first line. 700 701 // Then create our output line-by-line 702 auto nameLines = nameText.splitter('\n'); 703 auto descriptionLines = descriptionText.splitter('\n'); 704 705 bool isFirstLine = true; 706 size_t nameLength = 0; 707 while(!nameLines.empty || !descriptionLines.empty) 708 { 709 if(!nameLines.empty) 710 { 711 nameLength = nameLines.front.length; // Need to keep track of this for the next two ifs. 712 713 output ~= nameLines.front; 714 nameLines.popFront(); 715 } 716 else 717 nameLength = 0; 718 719 if(isFirstLine) 720 { 721 // Push the middle affix into the middle, where it should be. 722 const ptrdiff_t missingChars = (NAME_CHARS - nameLength) + nameOptions.linePrefix.length; 723 if(missingChars > 0) 724 output ~= descriptionOptions.linePrefix[0..missingChars]; 725 726 output ~= MIDDLE_AFFIX; 727 } 728 729 if(!descriptionLines.empty) 730 { 731 auto description = descriptionLines.front; 732 if(!isFirstLine) 733 description = description[nameLength..$]; // The name might be multi-line, so we need to adjust the padding. 734 735 output ~= description; 736 descriptionLines.popFront(); 737 } 738 739 output ~= "\n"; 740 isFirstLine = false; // IMPORTANT for it to be here, please don't move it. 741 } 742 } 743 744 // Void the last new line, as it provides consistency with HelpSectionTextContent 745 if(output.length > 0 && output[$-1] == '\n') 746 output = output[0..$-1]; 747 748 // debug 749 // { 750 // char[] a; 751 // a.length = nameOptions.linePrefix.length; 752 // a[] = '1'; 753 // output ~= a; 754 755 // a.length = NAME_CHARS; 756 // a[] = '2'; 757 // output ~= a; 758 759 // a.length = MIDDLE_AFFIX.length; 760 // a[] = '3'; 761 // output ~= a; 762 763 // a.length = DESCRIPTION_CHARS; 764 // a[] = '4'; 765 // output ~= a; 766 // output ~= '\n'; 767 768 // a.length = DESCRIPTION_START; 769 // a[] = '>'; 770 // output ~= a; 771 // output ~= '\n'; 772 773 // a.length = descriptionOptions.lineCharLimit; 774 // a[] = '*'; 775 // output ~= a; 776 // output ~= '\n'; 777 778 // a.length = options.lineCharLimit; 779 // a[] = '#'; 780 // output ~= a; 781 // } 782 783 return output.assumeUnique; 784 } 785 } 786 /// 787 unittest 788 { 789 auto content = new HelpSectionArgInfoContent( 790 [ 791 HelpSectionArgInfoContent.ArgInfo(["v", "verbose"], "Display detailed information about what the program is doing."), 792 HelpSectionArgInfoContent.ArgInfo(["f", "file"], "The input file."), 793 HelpSectionArgInfoContent.ArgInfo(["super","longer","names"], "Some unusuable command with long names and a long description.") 794 ], 795 796 AutoAddArgDashes.yes 797 ); 798 auto options = HelpSectionOptions( 799 " ", 800 80 801 ); 802 803 assert(content.getContent(options) == 804 " -v,--verbose - Display detailed information about what the program is\n" 805 ~" doing.\n" 806 ~" -f,--file - The input file.\n" 807 ~" --super,--longer, - Some unusuable command with long names and a long desc\n" 808 ~" --names ription.", 809 810 "\n"~content.getContent(options) 811 ); 812 }