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