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, ParameterNames;
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 /// A positional 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 = ParameterNames!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 (isCallable!FUN)
203 {
204 	alias Params = staticMap!(Unqual, ParameterTypeTuple!FUN);
205 	Params values;
206 	enum names = optionNames!FUN;
207 	static immutable bool[] isIndexParameter = [staticMap!(isParameter, Params)];
208 	alias defaults = ParameterDefaultValueTuple!FUN;
209 
210 	foreach (i, defaultValue; defaults)
211 	{
212 		static if (!is(defaultValue == void))
213 		{
214 			//values[i] = defaultValue;
215 			// https://issues.dlang.org/show_bug.cgi?id=13252
216 			values[i] = cast(OptionValueType!(Params[i])) defaultValue;
217 		}
218 	}
219 
220 	// Can't pass options with empty names to getopt, filter them out.
221 	static immutable string[] namesArr = [names];
222 	static immutable bool[] optionUseGetOpt = Params.length.iota.map!(n => namesArr[n].length > 0 && !isIndexParameter[n]).array;
223 
224 	enum structFields =
225 		config.getoptConfig.length.iota.map!(n => "std.getopt.config config%d = std.getopt.config.%s;\n".format(n, config.getoptConfig[n])).join() ~
226 		Params.length.iota
227 			.filter!(n => optionUseGetOpt[n])
228 			.map!(n => "string selector%d; OptionValueType!(Params[%d])* value%d;\n".format(n, n, n)).join();
229 
230 	static struct GetOptArgs { mixin(structFields); }
231 	GetOptArgs getOptArgs;
232 
233 	static string optionSelector(int i)()
234 	{
235 		string[] variants;
236 		auto shorthand = optionShorthand!(Params[i]);
237 		if (shorthand)
238 			variants ~= [shorthand];
239 		enum keywords = names[i].identifierToCommandLineKeywords();
240 		variants ~= keywords;
241 		return variants.join("|");
242 	}
243 
244 	foreach (i, ref value; values)
245 		static if (optionUseGetOpt[i])
246 		{
247 			enum selector = optionSelector!i();
248 			mixin("getOptArgs.selector%d = selector;".format(i));
249 			mixin("getOptArgs.value%d = optionValue(values[%d]);".format(i, i));
250 		}
251 
252 	auto origArgs = args;
253 	bool help;
254 
255 	getopt(args,
256 		std.getopt.config.bundling,
257 		std.getopt.config.caseSensitive,
258 		getOptArgs.tupleof,
259 		"h|help", &help,
260 	);
261 
262 	void printUsage()
263 	{
264 		usageFun(getUsage!FUN(origArgs[0]));
265 	}
266 
267 	if (help)
268 	{
269 		printUsage();
270 		static if (is(ReturnType!FUN == void))
271 			return;
272 		else
273 			return ReturnType!FUN.init;
274 	}
275 
276 	args = args[1..$];
277 
278 	// Slurp remaining, unparsed arguments into parameter fields
279 
280 	foreach (i, ref value; values)
281 	{
282 		alias T = Params[i];
283 		static if (isParameter!T)
284 		{
285 			static if (isOptionArray!(OptionValueType!T))
286 			{
287 				values[i] = args.map!(arg => arg.to!(ElementType!(OptionValueType!T))).array;
288 				args = null;
289 			}
290 			else
291 			{
292 				if (args.length)
293 				{
294 					values[i] = to!(OptionValueType!T)(args[0]);
295 					args = args[1..$];
296 				}
297 				else
298 				{
299 					static if (is(defaults[i] == void))
300 					{
301 						// If the first argument is mandatory,
302 						// and no arguments were given, print usage.
303 						if (origArgs.length == 1)
304 							printUsage();
305 
306 						enum plainName = names[i].identifierToPlainText;
307 						throw new GetOptException("No " ~ plainName ~ " specified.");
308 					}
309 				}
310 			}
311 		}
312 	}
313 
314 	if (args.length)
315 		throw new GetOptException("Extra parameters specified: %(%s %)".format(args));
316 
317 	return FUN(values);
318 }
319 
320 debug(ae_unittest) unittest
321 {
322 	void f1(bool verbose, Option!int tries, string filename)
323 	{
324 		assert(verbose);
325 		assert(tries == 5);
326 		assert(filename == "filename.ext");
327 	}
328 	funopt!f1(["program", "--verbose", "--tries", "5", "filename.ext"]);
329 
330 	void f2(string a, Parameter!string b, string[] rest)
331 	{
332 		assert(a == "a");
333 		assert(b == "b");
334 		assert(rest == ["c", "d"]);
335 	}
336 	funopt!f2(["program", "a", "b", "c", "d"]);
337 
338 	void f3(Option!(string[], null, "DIR", 'x') excludeDir)
339 	{
340 		assert(excludeDir == ["a", "b", "c"]);
341 	}
342 	funopt!f3(["program", "--excludedir", "a", "--exclude-dir", "b", "-x", "c"]);
343 
344 	void f4(Option!string outputFile = "output.txt", string inputFile = "input.txt", string[] dataFiles = null)
345 	{
346 		assert(inputFile == "input.txt");
347 		assert(outputFile == "output.txt");
348 		assert(dataFiles == []);
349 	}
350 	funopt!f4(["program"]);
351 
352 	void f5(string input = null)
353 	{
354 		assert(input is null);
355 	}
356 	funopt!f5(["program"]);
357 
358 	funopt!({})(["program"]);
359 
360 	funopt!((int) {})(["program", "5"]);
361 
362 	funopt!((int n) { assert(n); })(["program", "5"]);
363 
364 	funopt!((int[] n) { assert(n == [12, 34]); })(["program", "12", "34"]);
365 }
366 
367 // ***************************************************************************
368 
369 private string canonicalizeCommandLineArgument(string s) { return s.replace("-", ""); }
370 private string canonicalizeIdentifier(string s) { return s.chomp("_").toLower(); }
371 private string identifierToCommandLineKeyword(string s) { return s.length ? s.chomp("_").splitByCamelCase.join("-").toLower() : "parameter"; }
372 private string identifierToCommandLineParam  (string s) { return s.length ? s.chomp("_").splitByCamelCase.join("-").toUpper() : "PARAMETER"; }
373 private string identifierToPlainText         (string s) { return s.length ? s.chomp("_").splitByCamelCase.join(" ").toLower() : "parameter"; }
374 private string[] identifierToCommandLineKeywords(string s) { auto words = s.chomp("_").splitByCamelCase(); return [words.join().toLower()] ~ (words.length > 1 ? [words.join("-").toLower()] : []); } /// for getopt
375 
376 private string getProgramName(string program)
377 {
378 	auto programName = program.baseName();
379 	version(Windows)
380 	{
381 		programName = programName.toLower();
382 		if (programName.extension == ".exe")
383 			programName = programName.stripExtension();
384 	}
385 
386 	return programName;
387 }
388 
389 private string escapeFmt(string s) { return s.replace("%", "%%"); }
390 
391 /// Constructs the `funopt` usage string.
392 string getUsage(alias FUN)(string program)
393 {
394 	auto programName = getProgramName(program);
395 	enum formatString = getUsageFormatString!FUN();
396 	return formatString.format(programName);
397 }
398 
399 /// Constructs the `funopt` usage format string.
400 /// `"%1$s"` is used instead of the program name.
401 string getUsageFormatString(alias FUN)()
402 {
403 	alias ParameterTypeTuple!FUN Params;
404 	enum names = [optionNames!FUN];
405 	alias defaults = ParameterDefaultValueTuple!FUN;
406 
407 	string result = "Usage: %1$s";
408 	enum inSynopsis(Param) = isParameter!Param || !optionHasDescription!Param;
409 	enum haveOmittedOptions = !allSatisfy!(inSynopsis, Params);
410 	static if (haveOmittedOptions)
411 	{
412 		enum haveRequiredOptions = (){
413 			bool result = false;
414 			foreach (i, Param; Params)
415 				static if (!inSynopsis!Param && Param.type == OptionType.option && is(defaults[i] == void))
416 					result = true;
417 			return result;
418 		}();
419 		static if (haveRequiredOptions)
420 			result ~= " OPTION...";
421 		else
422 			result ~= " [OPTION]...";
423 	}
424 
425 	string getSwitchText(int i)()
426 	{
427 		alias Param = Params[i];
428 		static if (isParameter!Param)
429 			return names[i].identifierToCommandLineParam();
430 		else
431 		{
432 			string switchText = "--" ~ names[i].identifierToCommandLineKeyword();
433 			static if (is(Param == _OptionImpl!Args, Args...))
434 				static if (Param.type == OptionType.option)
435 					switchText ~= (optionPlaceholder!Param.canFind('=') ? ' ' : '=') ~ optionPlaceholder!Param;
436 			return switchText;
437 		}
438 	}
439 
440 	string optionalEnd;
441 	void flushOptional() { result ~= optionalEnd; optionalEnd = null; }
442 	foreach (i, Param; Params)
443 		static if (!isHiddenOption!Param && inSynopsis!Param)
444 		{
445 			static if (isParameter!Param)
446 			{
447 				result ~= " ";
448 				static if (!is(defaults[i] == void))
449 				{
450 					result ~= "[";
451 					optionalEnd ~= "]";
452 				}
453 				result ~= names[i].identifierToCommandLineParam();
454 			}
455 			else
456 			{
457 				flushOptional();
458 				result ~= " [" ~ getSwitchText!i().escapeFmt() ~ "]";
459 			}
460 			static if (isOptionArray!Param)
461 				result ~= "...";
462 		}
463 	flushOptional();
464 
465 	result ~= "\n";
466 	static if (hasAttribute!(string, FUN))
467 	{
468 		enum description = getAttribute!(string, FUN);
469 		result ~= "\n" ~ description ~ "\n";
470 	}
471 
472 	enum haveDescriptions = anySatisfy!(optionHasDescription, Params);
473 	static if (haveDescriptions)
474 	{
475 		enum haveShorthands = anySatisfy!(optionShorthand, Params);
476 		string[Params.length] selectors;
477 		size_t longestSelector;
478 
479 		foreach (i, Param; Params)
480 			static if (optionHasDescription!Param)
481 			{
482 				string switchText = getSwitchText!i();
483 				if (haveShorthands)
484 				{
485 					auto c = optionShorthand!Param;
486 					if (c)
487 						selectors[i] = "-%s, %s".format(c, switchText);
488 					else
489 						selectors[i] = "    %s".format(switchText);
490 				}
491 				else
492 					selectors[i] = switchText;
493 				longestSelector = max(longestSelector, selectors[i].length);
494 			}
495 
496 		result ~= "\nOptions:\n";
497 		foreach (i, Param; Params)
498 			static if (optionHasDescription!Param)
499 				result ~= optionWrap(optionDescription!Param.escapeFmt(), selectors[i], longestSelector);
500 	}
501 
502 	return result;
503 }
504 
505 /// Performs line wrapping for option descriptions.
506 string optionWrap(string text, string firstIndent, size_t indentWidth)
507 {
508 	enum width = 79;
509 	auto padding = " ".replicate(2 + indentWidth + 2);
510 	text = text.findSplit("\n\n")[0];
511 	auto paragraphs = text.split1("\n");
512 	auto result = verbatimWrap(
513 		paragraphs[0],
514 		width,
515 		"  %-*s  ".format(indentWidth, firstIndent),
516 		padding
517 	);
518 	result ~= paragraphs[1..$].map!(p => verbatimWrap(p, width, padding, padding)).join();
519 	return result;
520 }
521 
522 debug(ae_unittest) unittest
523 {
524 	void f1(
525 		Switch!("Enable verbose logging", 'v') verbose,
526 		Option!(int, "Number of tries") tries,
527 		Option!(int, "Seconds to\nwait each try", "SECS", 0, "timeout") t,
528 		in string filename,
529 		string output = "default",
530 		string[] extraFiles = null
531 	)
532 	{}
533 
534 	auto usage = getUsage!f1("program");
535 	assert(usage ==
536 "Usage: program OPTION... FILENAME [OUTPUT [EXTRA-FILES...]]
537 
538 Options:
539   -v, --verbose       Enable verbose logging
540       --tries=N       Number of tries
541       --timeout=SECS  Seconds to
542                       wait each try
543 ", usage);
544 
545 	void f2(
546 		bool verbose,
547 		Option!(string[]) extraFile,
548 		string filename,
549 		string output = "default",
550 	)
551 	{}
552 
553 	usage = getUsage!f2("program");
554 	assert(usage ==
555 "Usage: program [--verbose] [--extra-file=STR]... FILENAME [OUTPUT]
556 ", usage);
557 
558 	void f3(
559 		Parameter!(string[]) args = null,
560 	)
561 	{}
562 
563 	usage = getUsage!f3("program");
564 	assert(usage ==
565 "Usage: program [ARGS...]
566 ", usage);
567 
568 	void f4(
569 		Parameter!(string[], "The program arguments.") args = null,
570 	)
571 	{}
572 
573 	usage = getUsage!f4("program");
574 	assert(usage ==
575 "Usage: program [ARGS...]
576 
577 Options:
578   ARGS  The program arguments.
579 ", usage);
580 
581 	void f5(
582 		Option!(string[], "Features to disable.") without = null,
583 	)
584 	{}
585 
586 	usage = getUsage!f5("program");
587 	assert(usage ==
588 "Usage: program [OPTION]...
589 
590 Options:
591   --without=STR  Features to disable.
592 ", usage);
593 
594 	// If all options are on the command line, don't add "[OPTION]..."
595 	void f6(
596 		bool verbose,
597 		Parameter!(string[], "Files to transmogrify.") files = null,
598 	)
599 	{}
600 
601 	usage = getUsage!f6("program");
602 	assert(usage ==
603 "Usage: program [--verbose] [FILES...]
604 
605 Options:
606   FILES  Files to transmogrify.
607 ", usage);
608 
609 	// Ensure % characters work as expected.
610 	void f7(
611 		Parameter!(int, "How much power % to use.") powerPct,
612 	)
613 	{}
614 
615 	usage = getUsage!f7("program");
616 	assert(usage ==
617 "Usage: program POWER-PCT
618 
619 Options:
620   POWER-PCT  How much power % to use.
621 ", usage);
622 
623 	// Test program descriptions
624 	@(`Refrobnicates the transmogrifier.`)
625 	void f8(Switch!"Be verbose." verbose){}
626 
627 	usage = getUsage!f8("program");
628 	assert(usage ==
629 "Usage: program [OPTION]...
630 
631 Refrobnicates the transmogrifier.
632 
633 Options:
634   --verbose  Be verbose.
635 ", usage);
636 }
637 
638 // ***************************************************************************
639 
640 private string escapeRoff(string s)
641 {
642 	string result;
643 	foreach (c; s)
644 	{
645 		if (c == '\\')
646 			result ~= '\\';
647 		result ~= c;
648 	}
649 	return result;
650 }
651 
652 /// Constructs a roff man page based on the `funopt` usage.
653 string generateManPage(alias FUN)(
654 	/// Program name, as it should appear in the header, synopsis etc.
655 	/// If not specified, the identifier name of FUN is used.
656 	string programName = null,
657 	/// Long description for the DESCRIPTION section.
658 	/// If not specified, we either use the function string UDA, or omit the section entirely.
659 	string longDescription = null,
660 	/// Short description for the NAME section.
661 	/// If not specified, generated from the function string UDA.
662 	string shortDescription = null,
663 	/// Additional sections (BUGS, AUTHORS) to add at the end.
664 	string footer = null,
665 	/// Manual section
666 	int section = 1,
667 )
668 {
669 	if (!programName)
670 		programName = __traits(identifier, FUN);
671 
672 	string funDescription;
673 	static if (hasAttribute!(string, FUN))
674 		funDescription = getAttribute!(string, FUN).escapeRoff;
675 
676 	string otherDescription;
677 	if (funDescription)
678 		otherDescription = funDescription;
679 	else
680 		otherDescription = longDescription;
681 
682 	if (!shortDescription && otherDescription)
683 	{
684 		auto parts = (otherDescription ~ " ").findSplit(". ");
685 		if (!parts[2].length && otherDescription is longDescription)
686 			longDescription = null; // Move into shortDescription
687 		shortDescription = parts[0]
688 			.I!(s => toLower(s[0 .. 1]) ~ s[1 .. $]);
689 		shortDescription.skipOver(programName ~ " ");
690 	}
691 
692 	// Bold the program name at the start of the description
693 	if (longDescription.skipOver(programName ~ " "))
694 		longDescription = ".B " ~ programName ~ "\n" ~ longDescription;
695 
696 	alias ParameterTypeTuple!FUN Params;
697 	enum names = [optionNames!FUN];
698 	alias defaults = ParameterDefaultValueTuple!FUN;
699 
700 	string result;
701 	result ~= ".TH %s %d\n".format(programName.toUpper, section);
702 	result ~= ".SH NAME\n";
703 	if (shortDescription)
704 		result ~= "%s \\- %s\n".format(programName, shortDescription);
705 	else
706 		result ~= "%s\n".format(programName);
707 	result ~= ".SH SYNOPSIS\n";
708 	result ~= "\\fB%s\\fP".format(programName);
709 
710 	enum inSynopsis(Param) = isParameter!Param || !optionHasDescription!Param;
711 	enum haveOmittedOptions = !allSatisfy!(inSynopsis, Params);
712 	static if (haveOmittedOptions)
713 	{
714 		enum haveRequiredOptions = /*function bool*/(){
715 			bool result = false;
716 			foreach (i, Param; Params)
717 				static if (!inSynopsis!Param && Param.type == OptionType.option && is(defaults[i] == void))
718 					result = true;
719 			return result;
720 		}();
721 		static if (haveRequiredOptions)
722 			result ~= " \\fIOPTION\\fP...";
723 		else
724 			result ~= " [\\fIOPTION\\fP]...";
725 	}
726 
727 	string getSwitchText(int i)()
728 	{
729 		alias Param = Params[i];
730 		static if (isParameter!Param)
731 			return "\\fI" ~ names[i].identifierToCommandLineParam() ~ "\\fP";
732 		else
733 		{
734 			string switchText = "\\fB--" ~ names[i].identifierToCommandLineKeyword() ~ "\\fP";
735 			static if (is(Param == _OptionImpl!Args, Args...))
736 				static if (Param.type == OptionType.option)
737 					switchText ~= (optionPlaceholder!Param.canFind('=') ? ' ' : '=') ~ "\\fI" ~ optionPlaceholder!Param ~ "\\fP";
738 			return switchText;
739 		}
740 	}
741 
742 	string optionalEnd;
743 	void flushOptional() { result ~= optionalEnd; optionalEnd = null; }
744 	foreach (i, Param; Params)
745 		static if (!isHiddenOption!Param && inSynopsis!Param)
746 		{
747 			static if (isParameter!Param)
748 			{
749 				result ~= " ";
750 				static if (!is(defaults[i] == void))
751 				{
752 					result ~= "[";
753 					optionalEnd ~= "]";
754 				}
755 				result ~= "\\fI" ~ names[i].identifierToCommandLineParam() ~ "\\fP";
756 			}
757 			else
758 			{
759 				flushOptional();
760 				result ~= " [" ~ getSwitchText!i() ~ "]";
761 			}
762 			static if (isOptionArray!Param)
763 				result ~= "...";
764 		}
765 	flushOptional();
766 	result ~= "\n";
767 
768 	if (longDescription)
769 	{
770 		result ~= ".SH DESCRIPTION\n";
771 		result ~= longDescription;
772 		result ~= "\n";
773 	}
774 
775 	enum haveDescriptions = anySatisfy!(optionHasDescription, Params);
776 	static if (haveDescriptions)
777 	{
778 		result ~= ".SH OPTIONS\n\n";
779 
780 		foreach (i, Param; Params)
781 			static if (optionHasDescription!Param)
782 			{
783 				result ~= ".TP\n";
784 				auto c = optionShorthand!Param;
785 				if (c)
786 					result ~= "\\fB-%s\\fP, ".format(c);
787 				auto description = optionDescription!Param
788 					.replace("\n", "\n\n")
789 					.escapeRoff;
790 				result ~= getSwitchText!i() ~ "\n" ~ description ~ "\n\n";
791 			}
792 	}
793 
794 	result ~= footer;
795 
796 	return result;
797 }
798 
799 debug(ae_unittest) unittest
800 {
801 	@(`Frobnicates whatsits.`)
802 	void f1(
803 		Switch!("Enable verbose logging", 'v') verbose,
804 		Option!(int, "Number of tries") tries,
805 		Option!(int, "Seconds to wait each try", "SECS", 0, "timeout") t,
806 		in string filename,
807 		string output = "default",
808 		string[] extraFiles = null
809 	)
810 	{}
811 
812 	auto man = generateManPage!f1();
813 	assert(man ==
814 `.TH F1 1
815 .SH NAME
816 f1 \- frobnicates whatsits
817 .SH SYNOPSIS
818 \fBf1\fP \fIOPTION\fP... \fIFILENAME\fP [\fIOUTPUT\fP [\fIEXTRA-FILES\fP...]]
819 .SH OPTIONS
820 
821 .TP
822 \fB-v\fP, \fB--verbose\fP
823 Enable verbose logging
824 
825 .TP
826 \fB--tries\fP=\fIN\fP
827 Number of tries
828 
829 .TP
830 \fB--timeout\fP=\fISECS\fP
831 Seconds to wait each try
832 
833 `, man);
834 }
835 
836 // ***************************************************************************
837 
838 /// Dispatch the command line to a type's static methods, according to the
839 /// first parameter on the given command line (the "action").
840 /// String UDAs are used as usage documentation for generating --help output
841 /// (or when no action is specified).
842 auto funoptDispatch(alias Actions, FunOptConfig config = FunOptConfig.init, alias usageFun = defaultUsageFun)(string[] args)
843 {
844 	string program = args[0];
845 
846 	auto funImpl(string action, string[] actionArguments = [])
847 	{
848 		action = action.canonicalizeCommandLineArgument();
849 
850 		foreach (m; __traits(allMembers, Actions))
851 			static if (is(typeof(hasAttribute!(string, __traits(getMember, Actions, m)))))
852 			{
853 				alias member = I!(__traits(getMember, Actions, m));
854 				enum name = m.canonicalizeIdentifier();
855 				if (name == action)
856 				{
857 					auto args = [getProgramName(program) ~ " " ~ action] ~ actionArguments;
858 					static if (is(member == struct))
859 						return funoptDispatch!(member, config, usageFun)(args);
860 					else
861 						return funopt!(member, config, usageFun)(args);
862 				}
863 			}
864 
865 		throw new GetOptException("Unknown action: " ~ action);
866 	}
867 
868 	static if (hasAttribute!(string, Actions))
869 	{
870 		@(getAttribute!(string, Actions))
871 		auto fun(string action, string[] actionArguments = []) { return funImpl(action, actionArguments); }
872 	}
873 	else
874 		auto fun(string action, string[] actionArguments = []) { return funImpl(action, actionArguments); }
875 
876 	static void myUsageFun(string usage) { usageFun(usage ~ funoptDispatchUsage!Actions()); }
877 
878 	const FunOptConfig myConfig = (){
879 		auto c = config;
880 		c.getoptConfig ~= std.getopt.config.stopOnFirstNonOption;
881 		return c;
882 	}();
883 	return funopt!(fun, myConfig, myUsageFun)(args);
884 }
885 
886 /// Constructs the `funoptDispatch` usage string.
887 string funoptDispatchUsage(alias Actions)()
888 {
889 	string result = "\nActions:\n";
890 
891 	size_t longestAction = 0;
892 	foreach (m; __traits(allMembers, Actions))
893 		static if (is(typeof(hasAttribute!(string, __traits(getMember, Actions, m)))))
894 			static if (hasAttribute!(string, __traits(getMember, Actions, m)))
895 			{
896 				enum length = m.identifierToCommandLineKeyword().length;
897 				longestAction = max(longestAction, length);
898 			}
899 
900 	foreach (m; __traits(allMembers, Actions))
901 		static if (is(typeof(hasAttribute!(string, __traits(getMember, Actions, m)))))
902 			static if (hasAttribute!(string, __traits(getMember, Actions, m)))
903 			{
904 				enum name = m.identifierToCommandLineKeyword();
905 				//__traits(comment, __traits(getMember, Actions, m)) // https://github.com/D-Programming-Language/dmd/pull/3531
906 				result ~= optionWrap(getAttribute!(string, __traits(getMember, Actions, m)), name, longestAction);
907 			}
908 
909 	return result;
910 }
911 
912 debug(ae_unittest) unittest
913 {
914 	@(`Test program.`)
915 	struct Actions
916 	{
917 		@(`Perform action f1`)
918 		static void f1(bool verbose) {}
919 
920 		@(`Perform complicated action f2
921 
922 This action is complicated because of reasons.`)
923 		static void f2() {}
924 
925 		@(`An action sub-group`)
926 		struct fooBar
927 		{
928 			@(`Create a new foobar`)
929 			static void new_() {}
930 		}
931 	}
932 
933 	funoptDispatch!Actions(["program", "f1", "--verbose"]);
934 	funoptDispatch!Actions(["program", "foo-bar", "new"]);
935 
936 	static string usage;
937 	static void usageFun(string _usage) { usage = _usage; }
938 	funoptDispatch!(Actions, FunOptConfig.init, usageFun)(["unittest", "--help"]);
939 	assert(usage == "Usage: unittest ACTION [ACTION-ARGUMENTS...]
940 
941 Test program.
942 
943 Actions:
944   f1       Perform action f1
945   f2       Perform complicated action f2
946   foo-bar  An action sub-group
947 ", usage);
948 
949 	funoptDispatch!(Actions, FunOptConfig.init, usageFun)(["unittest", "f1", "--help"]);
950 	assert(usage == "Usage: unittest f1 [--verbose]
951 
952 Perform action f1
953 ", usage);
954 
955 	funoptDispatch!(Actions, FunOptConfig.init, usageFun)(["unittest", "f2", "--help"]);
956 	assert(usage == "Usage: unittest f2
957 
958 Perform complicated action f2
959 
960 This action is complicated because of reasons.
961 ", usage);
962 
963 	funoptDispatch!(Actions, FunOptConfig.init, usageFun)(["unittest", "foo-bar", "--help"]);
964 	assert(usage == "Usage: unittest foobar ACTION [ACTION-ARGUMENTS...]
965 
966 An action sub-group
967 
968 Actions:
969   new  Create a new foobar
970 ", usage);
971 }