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