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