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