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 	{
395 		enum haveRequiredOptions = (){
396 			bool result = false;
397 			foreach (i, Param; Params)
398 				static if (!inSynopsis!Param && Param.type == OptionType.option && is(defaults[i] == void))
399 					result = true;
400 			return result;
401 		}();
402 		static if (haveRequiredOptions)
403 			result ~= " OPTION...";
404 		else
405 			result ~= " [OPTION]...";
406 	}
407 
408 	string getSwitchText(int i)()
409 	{
410 		alias Param = Params[i];
411 		static if (isParameter!Param)
412 			return names[i].identifierToCommandLineParam();
413 		else
414 		{
415 			string switchText = "--" ~ names[i].identifierToCommandLineKeyword();
416 			static if (is(Param == _OptionImpl!Args, Args...))
417 				static if (Param.type == OptionType.option)
418 					switchText ~= (optionPlaceholder!Param.canFind('=') ? ' ' : '=') ~ optionPlaceholder!Param;
419 			return switchText;
420 		}
421 	}
422 
423 	string optionalEnd;
424 	void flushOptional() { result ~= optionalEnd; optionalEnd = null; }
425 	foreach (i, Param; Params)
426 		static if (!isHiddenOption!Param && inSynopsis!Param)
427 		{
428 			static if (isParameter!Param)
429 			{
430 				result ~= " ";
431 				static if (!is(defaults[i] == void))
432 				{
433 					result ~= "[";
434 					optionalEnd ~= "]";
435 				}
436 				result ~= names[i].identifierToCommandLineParam();
437 			}
438 			else
439 			{
440 				flushOptional();
441 				result ~= " [" ~ getSwitchText!i().escapeFmt() ~ "]";
442 			}
443 			static if (isOptionArray!Param)
444 				result ~= "...";
445 		}
446 	flushOptional();
447 
448 	result ~= "\n";
449 	static if (hasAttribute!(string, FUN))
450 	{
451 		enum description = getAttribute!(string, FUN);
452 		result ~= "\n" ~ description ~ "\n";
453 	}
454 
455 	enum haveDescriptions = anySatisfy!(optionHasDescription, Params);
456 	static if (haveDescriptions)
457 	{
458 		enum haveShorthands = anySatisfy!(optionShorthand, Params);
459 		string[Params.length] selectors;
460 		size_t longestSelector;
461 
462 		foreach (i, Param; Params)
463 			static if (optionHasDescription!Param)
464 			{
465 				string switchText = getSwitchText!i();
466 				if (haveShorthands)
467 				{
468 					auto c = optionShorthand!Param;
469 					if (c)
470 						selectors[i] = "-%s, %s".format(c, switchText);
471 					else
472 						selectors[i] = "    %s".format(switchText);
473 				}
474 				else
475 					selectors[i] = switchText;
476 				longestSelector = max(longestSelector, selectors[i].length);
477 			}
478 
479 		result ~= "\nOptions:\n";
480 		foreach (i, Param; Params)
481 			static if (optionHasDescription!Param)
482 				result ~= optionWrap(optionDescription!Param.escapeFmt(), selectors[i], longestSelector);
483 	}
484 
485 	return result;
486 }
487 
488 /// Performs line wrapping for option descriptions.
489 string optionWrap(string text, string firstIndent, size_t indentWidth)
490 {
491 	enum width = 79;
492 	auto padding = " ".replicate(2 + indentWidth + 2);
493 	text = text.findSplit("\n\n")[0];
494 	auto paragraphs = text.split1("\n");
495 	auto result = verbatimWrap(
496 		paragraphs[0],
497 		width,
498 		"  %-*s  ".format(indentWidth, firstIndent),
499 		padding
500 	);
501 	result ~= paragraphs[1..$].map!(p => verbatimWrap(p, width, padding, padding)).join();
502 	return result;
503 }
504 
505 unittest
506 {
507 	void f1(
508 		Switch!("Enable verbose logging", 'v') verbose,
509 		Option!(int, "Number of tries") tries,
510 		Option!(int, "Seconds to\nwait each try", "SECS", 0, "timeout") t,
511 		in string filename,
512 		string output = "default",
513 		string[] extraFiles = null
514 	)
515 	{}
516 
517 	auto usage = getUsage!f1("program");
518 	assert(usage ==
519 "Usage: program OPTION... FILENAME [OUTPUT [EXTRA-FILES...]]
520 
521 Options:
522   -v, --verbose       Enable verbose logging
523       --tries=N       Number of tries
524       --timeout=SECS  Seconds to
525                       wait each try
526 ", usage);
527 
528 	void f2(
529 		bool verbose,
530 		Option!(string[]) extraFile,
531 		string filename,
532 		string output = "default",
533 	)
534 	{}
535 
536 	usage = getUsage!f2("program");
537 	assert(usage ==
538 "Usage: program [--verbose] [--extra-file=STR]... FILENAME [OUTPUT]
539 ", usage);
540 
541 	void f3(
542 		Parameter!(string[]) args = null,
543 	)
544 	{}
545 
546 	usage = getUsage!f3("program");
547 	assert(usage ==
548 "Usage: program [ARGS...]
549 ", usage);
550 
551 	void f4(
552 		Parameter!(string[], "The program arguments.") args = null,
553 	)
554 	{}
555 
556 	usage = getUsage!f4("program");
557 	assert(usage ==
558 "Usage: program [ARGS...]
559 
560 Options:
561   ARGS  The program arguments.
562 ", usage);
563 
564 	void f5(
565 		Option!(string[], "Features to disable.") without = null,
566 	)
567 	{}
568 
569 	usage = getUsage!f5("program");
570 	assert(usage ==
571 "Usage: program [OPTION]...
572 
573 Options:
574   --without=STR  Features to disable.
575 ", usage);
576 
577 	// If all options are on the command line, don't add "[OPTION]..."
578 	void f6(
579 		bool verbose,
580 		Parameter!(string[], "Files to transmogrify.") files = null,
581 	)
582 	{}
583 
584 	usage = getUsage!f6("program");
585 	assert(usage ==
586 "Usage: program [--verbose] [FILES...]
587 
588 Options:
589   FILES  Files to transmogrify.
590 ", usage);
591 
592 	// Ensure % characters work as expected.
593 	void f7(
594 		Parameter!(int, "How much power % to use.") powerPct,
595 	)
596 	{}
597 
598 	usage = getUsage!f7("program");
599 	assert(usage ==
600 "Usage: program POWER-PCT
601 
602 Options:
603   POWER-PCT  How much power % to use.
604 ", usage);
605 
606 	// Test program descriptions
607 	@(`Refrobnicates the transmogrifier.`)
608 	void f8(Switch!"Be verbose." verbose){}
609 
610 	usage = getUsage!f8("program");
611 	assert(usage ==
612 "Usage: program [OPTION]...
613 
614 Refrobnicates the transmogrifier.
615 
616 Options:
617   --verbose  Be verbose.
618 ", usage);
619 }
620 
621 // ***************************************************************************
622 
623 private string escapeRoff(string s)
624 {
625 	string result;
626 	foreach (c; s)
627 	{
628 		if (c == '\\')
629 			result ~= '\\';
630 		result ~= c;
631 	}
632 	return result;
633 }
634 
635 /// Constructs a roff man page based on the `funopt` usage.
636 string generateManPage(alias FUN)(
637 	/// Program name, as it should appear in the header, synopsis etc.
638 	/// If not specified, the identifier name of FUN is used.
639 	string programName = null,
640 	/// Long description for the DESCRIPTION section.
641 	/// If not specified, we either use the function string UDA, or omit the section entirely.
642 	string longDescription = null,
643 	/// Short description for the NAME section.
644 	/// If not specified, generated from the function string UDA.
645 	string shortDescription = null,
646 	/// Additional sections (BUGS, AUTHORS) to add at the end.
647 	string footer = null,
648 	/// Manual section
649 	int section = 1,
650 )
651 {
652 	if (!programName)
653 		programName = __traits(identifier, FUN);
654 
655 	string funDescription;
656 	static if (hasAttribute!(string, FUN))
657 		funDescription = getAttribute!(string, FUN).escapeRoff;
658 
659 	string otherDescription;
660 	if (funDescription)
661 		otherDescription = funDescription;
662 	else
663 		otherDescription = longDescription;
664 
665 	if (!shortDescription && otherDescription)
666 	{
667 		auto parts = (otherDescription ~ " ").findSplit(". ");
668 		if (!parts[2].length && otherDescription is longDescription)
669 			longDescription = null; // Move into shortDescription
670 		shortDescription = parts[0]
671 			.I!(s => toLower(s[0 .. 1]) ~ s[1 .. $]);
672 		shortDescription.skipOver(programName ~ " ");
673 	}
674 
675 	// Bold the program name at the start of the description
676 	if (longDescription.skipOver(programName ~ " "))
677 		longDescription = ".B " ~ programName ~ "\n" ~ longDescription;
678 
679 	alias ParameterTypeTuple!FUN Params;
680 	enum names = [optionNames!FUN];
681 	alias defaults = ParameterDefaultValueTuple!FUN;
682 
683 	string result;
684 	result ~= ".TH %s %d\n".format(programName.toUpper, section);
685 	result ~= ".SH NAME\n";
686 	if (shortDescription)
687 		result ~= "%s \\- %s\n".format(programName, shortDescription);
688 	else
689 		result ~= "%s\n".format(programName);
690 	result ~= ".SH SYNOPSIS\n";
691 	result ~= "\\fB%s\\fP".format(programName);
692 
693 	enum inSynopsis(Param) = isParameter!Param || !optionHasDescription!Param;
694 	enum haveOmittedOptions = !allSatisfy!(inSynopsis, Params);
695 	static if (haveOmittedOptions)
696 	{
697 		enum haveRequiredOptions = /*function bool*/(){
698 			bool result = false;
699 			foreach (i, Param; Params)
700 				static if (!inSynopsis!Param && Param.type == OptionType.option && is(defaults[i] == void))
701 					result = true;
702 			return result;
703 		}();
704 		static if (haveRequiredOptions)
705 			result ~= " \\fIOPTION\\fP...";
706 		else
707 			result ~= " [\\fIOPTION\\fP]...";
708 	}
709 
710 	string getSwitchText(int i)()
711 	{
712 		alias Param = Params[i];
713 		static if (isParameter!Param)
714 			return "\\fI" ~ names[i].identifierToCommandLineParam() ~ "\\fP";
715 		else
716 		{
717 			string switchText = "\\fB--" ~ names[i].identifierToCommandLineKeyword() ~ "\\fP";
718 			static if (is(Param == _OptionImpl!Args, Args...))
719 				static if (Param.type == OptionType.option)
720 					switchText ~= (optionPlaceholder!Param.canFind('=') ? ' ' : '=') ~ "\\fI" ~ optionPlaceholder!Param ~ "\\fP";
721 			return switchText;
722 		}
723 	}
724 
725 	string optionalEnd;
726 	void flushOptional() { result ~= optionalEnd; optionalEnd = null; }
727 	foreach (i, Param; Params)
728 		static if (!isHiddenOption!Param && inSynopsis!Param)
729 		{
730 			static if (isParameter!Param)
731 			{
732 				result ~= " ";
733 				static if (!is(defaults[i] == void))
734 				{
735 					result ~= "[";
736 					optionalEnd ~= "]";
737 				}
738 				result ~= "\\fI" ~ names[i].identifierToCommandLineParam() ~ "\\fP";
739 			}
740 			else
741 			{
742 				flushOptional();
743 				result ~= " [" ~ getSwitchText!i() ~ "]";
744 			}
745 			static if (isOptionArray!Param)
746 				result ~= "...";
747 		}
748 	flushOptional();
749 	result ~= "\n";
750 
751 	if (longDescription)
752 	{
753 		result ~= ".SH DESCRIPTION\n";
754 		result ~= longDescription;
755 		result ~= "\n";
756 	}
757 
758 	enum haveDescriptions = anySatisfy!(optionHasDescription, Params);
759 	static if (haveDescriptions)
760 	{
761 		result ~= ".SH OPTIONS\n\n";
762 
763 		foreach (i, Param; Params)
764 			static if (optionHasDescription!Param)
765 			{
766 				result ~= ".TP\n";
767 				auto c = optionShorthand!Param;
768 				if (c)
769 					result ~= "\\fB-%s\\fP, ".format(c);
770 				auto description = optionDescription!Param
771 					.replace("\n", "\n\n")
772 					.escapeRoff;
773 				result ~= getSwitchText!i() ~ "\n" ~ description ~ "\n\n";
774 			}
775 	}
776 
777 	result ~= footer;
778 
779 	return result;
780 }
781 
782 unittest
783 {
784 	@(`Frobnicates whatsits.`)
785 	void f1(
786 		Switch!("Enable verbose logging", 'v') verbose,
787 		Option!(int, "Number of tries") tries,
788 		Option!(int, "Seconds to wait each try", "SECS", 0, "timeout") t,
789 		in string filename,
790 		string output = "default",
791 		string[] extraFiles = null
792 	)
793 	{}
794 
795 	auto man = generateManPage!f1();
796 	assert(man ==
797 `.TH F1 1
798 .SH NAME
799 f1 \- frobnicates whatsits
800 .SH SYNOPSIS
801 \fBf1\fP \fIOPTION\fP... \fIFILENAME\fP [\fIOUTPUT\fP [\fIEXTRA-FILES\fP...]]
802 .SH OPTIONS
803 
804 .TP
805 \fB-v\fP, \fB--verbose\fP
806 Enable verbose logging
807 
808 .TP
809 \fB--tries\fP=\fIN\fP
810 Number of tries
811 
812 .TP
813 \fB--timeout\fP=\fISECS\fP
814 Seconds to wait each try
815 
816 `, man);
817 }
818 
819 // ***************************************************************************
820 
821 /// Dispatch the command line to a type's static methods, according to the
822 /// first parameter on the given command line (the "action").
823 /// String UDAs are used as usage documentation for generating --help output
824 /// (or when no action is specified).
825 auto funoptDispatch(alias Actions, FunOptConfig config = FunOptConfig.init, alias usageFun = defaultUsageFun)(string[] args)
826 {
827 	string program = args[0];
828 
829 	auto fun(string action, string[] actionArguments = [])
830 	{
831 		action = action.canonicalizeCommandLineArgument();
832 
833 		foreach (m; __traits(allMembers, Actions))
834 			static if (is(typeof(hasAttribute!(string, __traits(getMember, Actions, m)))))
835 			{
836 				alias member = I!(__traits(getMember, Actions, m));
837 				enum name = m.canonicalizeIdentifier();
838 				if (name == action)
839 				{
840 					auto args = [getProgramName(program) ~ " " ~ action] ~ actionArguments;
841 					static if (is(member == struct))
842 						return funoptDispatch!(member, config, usageFun)(args);
843 					else
844 						return funopt!(member, config, usageFun)(args);
845 				}
846 			}
847 
848 		throw new GetOptException("Unknown action: " ~ action);
849 	}
850 
851 	static void myUsageFun(string usage) { usageFun(usage ~ funoptDispatchUsage!Actions()); }
852 
853 	const FunOptConfig myConfig = (){
854 		auto c = config;
855 		c.getoptConfig ~= std.getopt.config.stopOnFirstNonOption;
856 		return c;
857 	}();
858 	return funopt!(fun, myConfig, myUsageFun)(args);
859 }
860 
861 /// Constructs the `funoptDispatch` usage string.
862 string funoptDispatchUsage(alias Actions)()
863 {
864 	string result = "\nActions:\n";
865 
866 	size_t longestAction = 0;
867 	foreach (m; __traits(allMembers, Actions))
868 		static if (is(typeof(hasAttribute!(string, __traits(getMember, Actions, m)))))
869 			static if (hasAttribute!(string, __traits(getMember, Actions, m)))
870 			{
871 				enum length = m.identifierToCommandLineKeyword().length;
872 				longestAction = max(longestAction, length);
873 			}
874 
875 	foreach (m; __traits(allMembers, Actions))
876 		static if (is(typeof(hasAttribute!(string, __traits(getMember, Actions, m)))))
877 			static if (hasAttribute!(string, __traits(getMember, Actions, m)))
878 			{
879 				enum name = m.identifierToCommandLineKeyword();
880 				//__traits(comment, __traits(getMember, Actions, m)) // https://github.com/D-Programming-Language/dmd/pull/3531
881 				result ~= optionWrap(getAttribute!(string, __traits(getMember, Actions, m)), name, longestAction);
882 			}
883 
884 	return result;
885 }
886 
887 unittest
888 {
889 	struct Actions
890 	{
891 		@(`Perform action f1`)
892 		static void f1(bool verbose) {}
893 
894 		@(`Perform complicated action f2
895 
896 This action is complicated because of reasons.`)
897 		static void f2() {}
898 
899 		@(`An action sub-group`)
900 		struct fooBar
901 		{
902 			@(`Create a new foobar`)
903 			static void new_() {}
904 		}
905 	}
906 
907 	funoptDispatch!Actions(["program", "f1", "--verbose"]);
908 
909 	assert(funoptDispatchUsage!Actions() == "
910 Actions:
911   f1       Perform action f1
912   f2       Perform complicated action f2
913   foo-bar  An action sub-group
914 ");
915 
916 	funoptDispatch!Actions(["program", "foo-bar", "new"]);
917 
918 	assert(funoptDispatchUsage!(Actions.fooBar)() == "
919 Actions:
920   new  Create a new foobar
921 ");
922 
923 	static string usage;
924 	static void usageFun(string _usage) { usage = _usage; }
925 	funoptDispatch!(Actions, FunOptConfig.init, usageFun)(["unittest", "f1", "--help"]);
926 	assert(usage == "Usage: unittest f1 [--verbose]
927 
928 Perform action f1
929 ", usage);
930 
931 	funoptDispatch!(Actions, FunOptConfig.init, usageFun)(["unittest", "f2", "--help"]);
932 	assert(usage == "Usage: unittest f2
933 
934 Perform complicated action f2
935 
936 This action is complicated because of reasons.
937 ", usage);
938 }