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 		result ~= " [OPTION]...";
395 
396 	string getSwitchText(int i)()
397 	{
398 		alias Param = Params[i];
399 		static if (isParameter!Param)
400 			return names[i].identifierToCommandLineParam();
401 		else
402 		{
403 			string switchText = "--" ~ names[i].identifierToCommandLineKeyword();
404 			static if (is(Param == _OptionImpl!Args, Args...))
405 				static if (Param.type == OptionType.option)
406 					switchText ~= (optionPlaceholder!Param.canFind('=') ? ' ' : '=') ~ optionPlaceholder!Param;
407 			return switchText;
408 		}
409 	}
410 
411 	string optionalEnd;
412 	void flushOptional() { result ~= optionalEnd; optionalEnd = null; }
413 	foreach (i, Param; Params)
414 		static if (!isHiddenOption!Param && inSynopsis!Param)
415 		{
416 			static if (isParameter!Param)
417 			{
418 				result ~= " ";
419 				static if (!is(defaults[i] == void))
420 				{
421 					result ~= "[";
422 					optionalEnd ~= "]";
423 				}
424 				result ~= names[i].identifierToCommandLineParam();
425 			}
426 			else
427 			{
428 				flushOptional();
429 				result ~= " [" ~ getSwitchText!i().escapeFmt() ~ "]";
430 			}
431 			static if (isOptionArray!Param)
432 				result ~= "...";
433 		}
434 	flushOptional();
435 
436 	result ~= "\n";
437 	static if (hasAttribute!(string, FUN))
438 	{
439 		enum description = getAttribute!(string, FUN);
440 		result ~= "\n" ~ description ~ "\n";
441 	}
442 
443 	enum haveDescriptions = anySatisfy!(optionHasDescription, Params);
444 	static if (haveDescriptions)
445 	{
446 		enum haveShorthands = anySatisfy!(optionShorthand, Params);
447 		string[Params.length] selectors;
448 		size_t longestSelector;
449 
450 		foreach (i, Param; Params)
451 			static if (optionHasDescription!Param)
452 			{
453 				string switchText = getSwitchText!i();
454 				if (haveShorthands)
455 				{
456 					auto c = optionShorthand!Param;
457 					if (c)
458 						selectors[i] = "-%s, %s".format(c, switchText);
459 					else
460 						selectors[i] = "    %s".format(switchText);
461 				}
462 				else
463 					selectors[i] = switchText;
464 				longestSelector = max(longestSelector, selectors[i].length);
465 			}
466 
467 		result ~= "\nOptions:\n";
468 		foreach (i, Param; Params)
469 			static if (optionHasDescription!Param)
470 				result ~= optionWrap(optionDescription!Param.escapeFmt(), selectors[i], longestSelector);
471 	}
472 
473 	return result;
474 }
475 
476 /// Performs line wrapping for option descriptions.
477 string optionWrap(string text, string firstIndent, size_t indentWidth)
478 {
479 	enum width = 79;
480 	auto padding = " ".replicate(2 + indentWidth + 2);
481 	text = text.findSplit("\n\n")[0];
482 	auto paragraphs = text.split1("\n");
483 	auto result = verbatimWrap(
484 		paragraphs[0],
485 		width,
486 		"  %-*s  ".format(indentWidth, firstIndent),
487 		padding
488 	);
489 	result ~= paragraphs[1..$].map!(p => verbatimWrap(p, width, padding, padding)).join();
490 	return result;
491 }
492 
493 unittest
494 {
495 	void f1(
496 		Switch!("Enable verbose logging", 'v') verbose,
497 		Option!(int, "Number of tries") tries,
498 		Option!(int, "Seconds to\nwait each try", "SECS", 0, "timeout") t,
499 		in string filename,
500 		string output = "default",
501 		string[] extraFiles = null
502 	)
503 	{}
504 
505 	auto usage = getUsage!f1("program");
506 	assert(usage ==
507 "Usage: program [OPTION]... FILENAME [OUTPUT [EXTRA-FILES...]]
508 
509 Options:
510   -v, --verbose       Enable verbose logging
511       --tries=N       Number of tries
512       --timeout=SECS  Seconds to
513                       wait each try
514 ", usage);
515 
516 	void f2(
517 		bool verbose,
518 		Option!(string[]) extraFile,
519 		string filename,
520 		string output = "default",
521 	)
522 	{}
523 
524 	usage = getUsage!f2("program");
525 	assert(usage ==
526 "Usage: program [--verbose] [--extra-file=STR]... FILENAME [OUTPUT]
527 ", usage);
528 
529 	void f3(
530 		Parameter!(string[]) args = null,
531 	)
532 	{}
533 
534 	usage = getUsage!f3("program");
535 	assert(usage ==
536 "Usage: program [ARGS...]
537 ", usage);
538 
539 	void f4(
540 		Parameter!(string[], "The program arguments.") args = null,
541 	)
542 	{}
543 
544 	usage = getUsage!f4("program");
545 	assert(usage ==
546 "Usage: program [ARGS...]
547 
548 Options:
549   ARGS  The program arguments.
550 ", usage);
551 
552 	void f5(
553 		Option!(string[], "Features to disable.") without = null,
554 	)
555 	{}
556 
557 	usage = getUsage!f5("program");
558 	assert(usage ==
559 "Usage: program [OPTION]...
560 
561 Options:
562   --without=STR  Features to disable.
563 ", usage);
564 
565 	// If all options are on the command line, don't add "[OPTION]..."
566 	void f6(
567 		bool verbose,
568 		Parameter!(string[], "Files to transmogrify.") files = null,
569 	)
570 	{}
571 
572 	usage = getUsage!f6("program");
573 	assert(usage ==
574 "Usage: program [--verbose] [FILES...]
575 
576 Options:
577   FILES  Files to transmogrify.
578 ", usage);
579 
580 	// Ensure % characters work as expected.
581 	void f7(
582 		Parameter!(int, "How much power % to use.") powerPct,
583 	)
584 	{}
585 
586 	usage = getUsage!f7("program");
587 	assert(usage ==
588 "Usage: program POWER-PCT
589 
590 Options:
591   POWER-PCT  How much power % to use.
592 ", usage);
593 
594 	// Test program descriptions
595 	@(`Refrobnicates the transmogrifier.`)
596 	void f8(Switch!"Be verbose." verbose){}
597 
598 	usage = getUsage!f8("program");
599 	assert(usage ==
600 "Usage: program [OPTION]...
601 
602 Refrobnicates the transmogrifier.
603 
604 Options:
605   --verbose  Be verbose.
606 ", usage);
607 }
608 
609 // ***************************************************************************
610 
611 /// Dispatch the command line to a type's static methods, according to the
612 /// first parameter on the given command line (the "action").
613 /// String UDAs are used as usage documentation for generating --help output
614 /// (or when no action is specified).
615 auto funoptDispatch(alias Actions, FunOptConfig config = FunOptConfig.init, alias usageFun = defaultUsageFun)(string[] args)
616 {
617 	string program = args[0];
618 
619 	auto fun(string action, string[] actionArguments = [])
620 	{
621 		action = action.canonicalizeCommandLineArgument();
622 
623 		foreach (m; __traits(allMembers, Actions))
624 			static if (is(typeof(hasAttribute!(string, __traits(getMember, Actions, m)))))
625 			{
626 				alias member = I!(__traits(getMember, Actions, m));
627 				enum name = m.canonicalizeIdentifier();
628 				if (name == action)
629 				{
630 					auto args = [getProgramName(program) ~ " " ~ action] ~ actionArguments;
631 					static if (is(member == struct))
632 						return funoptDispatch!(member, config, usageFun)(args);
633 					else
634 						return funopt!(member, config, usageFun)(args);
635 				}
636 			}
637 
638 		throw new GetOptException("Unknown action: " ~ action);
639 	}
640 
641 	static void myUsageFun(string usage) { usageFun(usage ~ funoptDispatchUsage!Actions()); }
642 
643 	const FunOptConfig myConfig = (){
644 		auto c = config;
645 		c.getoptConfig ~= std.getopt.config.stopOnFirstNonOption;
646 		return c;
647 	}();
648 	return funopt!(fun, myConfig, myUsageFun)(args);
649 }
650 
651 /// Constructs the `funoptDispatch` usage string.
652 string funoptDispatchUsage(alias Actions)()
653 {
654 	string result = "\nActions:\n";
655 
656 	size_t longestAction = 0;
657 	foreach (m; __traits(allMembers, Actions))
658 		static if (is(typeof(hasAttribute!(string, __traits(getMember, Actions, m)))))
659 			static if (hasAttribute!(string, __traits(getMember, Actions, m)))
660 			{
661 				enum length = m.identifierToCommandLineKeyword().length;
662 				longestAction = max(longestAction, length);
663 			}
664 
665 	foreach (m; __traits(allMembers, Actions))
666 		static if (is(typeof(hasAttribute!(string, __traits(getMember, Actions, m)))))
667 			static if (hasAttribute!(string, __traits(getMember, Actions, m)))
668 			{
669 				enum name = m.identifierToCommandLineKeyword();
670 				//__traits(comment, __traits(getMember, Actions, m)) // https://github.com/D-Programming-Language/dmd/pull/3531
671 				result ~= optionWrap(getAttribute!(string, __traits(getMember, Actions, m)), name, longestAction);
672 			}
673 
674 	return result;
675 }
676 
677 unittest
678 {
679 	struct Actions
680 	{
681 		@(`Perform action f1`)
682 		static void f1(bool verbose) {}
683 
684 		@(`Perform complicated action f2
685 
686 This action is complicated because of reasons.`)
687 		static void f2() {}
688 
689 		@(`An action sub-group`)
690 		struct fooBar
691 		{
692 			@(`Create a new foobar`)
693 			static void new_() {}
694 		}
695 	}
696 
697 	funoptDispatch!Actions(["program", "f1", "--verbose"]);
698 
699 	assert(funoptDispatchUsage!Actions() == "
700 Actions:
701   f1       Perform action f1
702   f2       Perform complicated action f2
703   foo-bar  An action sub-group
704 ");
705 
706 	funoptDispatch!Actions(["program", "foo-bar", "new"]);
707 
708 	assert(funoptDispatchUsage!(Actions.fooBar)() == "
709 Actions:
710   new  Create a new foobar
711 ");
712 
713 	static string usage;
714 	static void usageFun(string _usage) { usage = _usage; }
715 	funoptDispatch!(Actions, FunOptConfig.init, usageFun)(["unittest", "f1", "--help"]);
716 	assert(usage == "Usage: unittest f1 [--verbose]
717 
718 Perform action f1
719 ", usage);
720 
721 	funoptDispatch!(Actions, FunOptConfig.init, usageFun)(["unittest", "f2", "--help"]);
722 	assert(usage == "Usage: unittest f2
723 
724 Perform complicated action f2
725 
726 This action is complicated because of reasons.
727 ", usage);
728 }