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;
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 {
198 	alias Params = staticMap!(Unqual, ParameterTypeTuple!FUN);
199 	Params values;
200 	enum names = optionNames!FUN;
201 	alias defaults = ParameterDefaultValueTuple!FUN;
202 
203 	foreach (i, defaultValue; defaults)
204 	{
205 		static if (!is(defaultValue == void))
206 		{
207 			//values[i] = defaultValue;
208 			// https://issues.dlang.org/show_bug.cgi?id=13252
209 			values[i] = cast(OptionValueType!(Params[i])) defaultValue;
210 		}
211 	}
212 
213 	enum structFields =
214 		config.getoptConfig.length.iota.map!(n => "std.getopt.config config%d = std.getopt.config.%s;\n".format(n, config.getoptConfig[n])).join() ~
215 		Params.length.iota.map!(n => "string selector%d; OptionValueType!(Params[%d])* value%d;\n".format(n, n, n)).join();
216 
217 	static struct GetOptArgs { mixin(structFields); }
218 	GetOptArgs getOptArgs;
219 
220 	static string optionSelector(int i)()
221 	{
222 		string[] variants;
223 		auto shorthand = optionShorthand!(Params[i]);
224 		if (shorthand)
225 			variants ~= [shorthand];
226 		enum words = names[i].splitByCamelCase();
227 		variants ~= words.join().toLower();
228 		if (words.length > 1)
229 			variants ~= words.join("-").toLower();
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 						throw new GetOptException("No " ~ names[i] ~ " specified.");
294 					}
295 				}
296 			}
297 		}
298 	}
299 
300 	if (args.length)
301 		throw new GetOptException("Extra parameters specified: %(%s %)".format(args));
302 
303 	return FUN(values);
304 }
305 
306 unittest
307 {
308 	void f1(bool verbose, Option!int tries, string filename)
309 	{
310 		assert(verbose);
311 		assert(tries == 5);
312 		assert(filename == "filename.ext");
313 	}
314 	funopt!f1(["program", "--verbose", "--tries", "5", "filename.ext"]);
315 
316 	void f2(string a, Parameter!string b, string[] rest)
317 	{
318 		assert(a == "a");
319 		assert(b == "b");
320 		assert(rest == ["c", "d"]);
321 	}
322 	funopt!f2(["program", "a", "b", "c", "d"]);
323 
324 	void f3(Option!(string[], null, "DIR", 'x') excludeDir)
325 	{
326 		assert(excludeDir == ["a", "b", "c"]);
327 	}
328 	funopt!f3(["program", "--excludedir", "a", "--exclude-dir", "b", "-x", "c"]);
329 
330 	void f4(Option!string outputFile = "output.txt", string inputFile = "input.txt", string[] dataFiles = null)
331 	{
332 		assert(inputFile == "input.txt");
333 		assert(outputFile == "output.txt");
334 		assert(dataFiles == []);
335 	}
336 	funopt!f4(["program"]);
337 
338 	void f5(string input = null)
339 	{
340 		assert(input is null);
341 	}
342 	funopt!f5(["program"]);
343 }
344 
345 // ***************************************************************************
346 
347 private string getProgramName(string program)
348 {
349 	auto programName = program.baseName();
350 	version(Windows)
351 	{
352 		programName = programName.toLower();
353 		if (programName.extension == ".exe")
354 			programName = programName.stripExtension();
355 	}
356 
357 	return programName;
358 }
359 
360 string getUsage(alias FUN)(string program)
361 {
362 	auto programName = getProgramName(program);
363 	enum formatString = getUsageFormatString!FUN();
364 	return formatString.format(programName);
365 }
366 
367 string getUsageFormatString(alias FUN)()
368 {
369 	alias ParameterTypeTuple!FUN Params;
370 	enum names = [optionNames!FUN];
371 	alias defaults = ParameterDefaultValueTuple!FUN;
372 
373 	string result = "Usage: %s";
374 	enum haveNonParameters = !allSatisfy!(isParameter, Params);
375 	enum haveDescriptions = anySatisfy!(optionHasDescription, Params);
376 	static if (haveNonParameters && haveDescriptions)
377 		result ~= " [OPTION]...";
378 
379 	string getSwitchText(int i)()
380 	{
381 		alias Param = Params[i];
382 		static if (isParameter!Param)
383 			return names[i].splitByCamelCase().join("-").toUpper();
384 		else
385 		{
386 			string switchText = "--" ~ names[i].splitByCamelCase().join("-").toLower();
387 			static if (is(Param == OptionImpl!Args, Args...))
388 				static if (Param.type == OptionType.option)
389 					switchText ~= (optionPlaceholder!Param.canFind('=') ? ' ' : '=') ~ optionPlaceholder!Param;
390 			return switchText;
391 		}
392 	}
393 
394 	foreach (i, Param; Params)
395 		static if (!isHiddenOption!Param && (isParameter!Param || !optionHasDescription!Param))
396 		{
397 			static if (isParameter!Param)
398 			{
399 				result ~= " ";
400 				static if (!is(defaults[i] == void))
401 					result ~= "[";
402 				result ~= names[i].splitByCamelCase().join("-").toUpper();
403 				static if (!is(defaults[i] == void))
404 					result ~= "]";
405 			}
406 			else
407 				result ~= " [" ~ getSwitchText!i() ~ "]";
408 			static if (isOptionArray!Param)
409 				result ~= "...";
410 		}
411 
412 	result ~= "\n";
413 
414 	static if (haveDescriptions)
415 	{
416 		enum haveShorthands = anySatisfy!(optionShorthand, Params);
417 		string[Params.length] selectors;
418 		size_t longestSelector;
419 
420 		foreach (i, Param; Params)
421 			static if (optionHasDescription!Param)
422 			{
423 				string switchText = getSwitchText!i();
424 				if (haveShorthands)
425 				{
426 					auto c = optionShorthand!Param;
427 					if (c)
428 						selectors[i] = "-%s, %s".format(c, switchText);
429 					else
430 						selectors[i] = "    %s".format(switchText);
431 				}
432 				else
433 					selectors[i] = switchText;
434 				longestSelector = max(longestSelector, selectors[i].length);
435 			}
436 
437 		result ~= "\nOptions:\n";
438 		foreach (i, Param; Params)
439 			static if (optionHasDescription!Param)
440 				result ~= optionWrap(optionDescription!Param, selectors[i], longestSelector);
441 	}
442 
443 	return result;
444 }
445 
446 string optionWrap(string text, string firstIndent, size_t indentWidth)
447 {
448 	enum width = 79;
449 	auto padding = " ".replicate(2 + indentWidth + 2);
450 	auto paragraphs = text.split("\n");
451 	auto result = wrap(
452 		paragraphs[0],
453 		width,
454 		"  %-*s  ".format(indentWidth, firstIndent),
455 		padding
456 	);
457 	result ~= paragraphs[1..$].map!(p => wrap(p, width, padding, padding)).join();
458 	return result;
459 }
460 
461 unittest
462 {
463 	void f1(
464 		Switch!("Enable verbose logging", 'v') verbose,
465 		Option!(int, "Number of tries") tries,
466 		Option!(int, "Seconds to\nwait each try", "SECS", 0, "timeout") t,
467 		in string filename,
468 		string output = "default",
469 		string[] extraFiles = null
470 	)
471 	{}
472 
473 	auto usage = getUsage!f1("program");
474 	assert(usage ==
475 "Usage: program [OPTION]... FILENAME [OUTPUT] [EXTRA-FILES]...
476 
477 Options:
478   -v, --verbose       Enable verbose logging
479       --tries=N       Number of tries
480       --timeout=SECS  Seconds to
481                       wait each try
482 ", usage);
483 
484 	void f2(
485 		bool verbose,
486 		Option!(string[]) extraFile,
487 		string filename,
488 		string output = "default",
489 	)
490 	{}
491 
492 	usage = getUsage!f2("program");
493 	assert(usage ==
494 "Usage: program [--verbose] [--extra-file=STR]... FILENAME [OUTPUT]
495 ", usage);
496 
497 	void f3(
498 		Parameter!(string[]) args = null,
499 	)
500 	{}
501 
502 	usage = getUsage!f3("program");
503 	assert(usage ==
504 "Usage: program [ARGS]...
505 ", usage);
506 
507 	void f4(
508 		Parameter!(string[], "The program arguments.") args = null,
509 	)
510 	{}
511 
512 	usage = getUsage!f4("program");
513 	assert(usage ==
514 "Usage: program [ARGS]...
515 
516 Options:
517   ARGS  The program arguments.
518 ", usage);
519 
520 	void f5(
521 		Option!(string[], "Features to disable.") without = null,
522 	)
523 	{}
524 
525 	usage = getUsage!f5("program");
526 	assert(usage ==
527 "Usage: program [OPTION]...
528 
529 Options:
530   --without=STR  Features to disable.
531 ", usage);
532 }
533 
534 // ***************************************************************************
535 
536 /// Dispatch the command line to a type's static methods, according to the
537 /// first parameter on the given command line (the "action").
538 /// String UDAs are used as usage documentation for generating --help output
539 /// (or when no action is specified).
540 auto funoptDispatch(alias Actions, FunOptConfig config = FunOptConfig.init, alias usageFun = defaultUsageFun)(string[] args)
541 {
542 	string program = args[0];
543 
544 	auto fun(string action, string[] actionArguments = [])
545 	{
546 		action = action.replace("-", "");
547 
548 		static void descUsageFun(string description)(string usage)
549 		{
550 			auto lines = usage.split("\n");
551 			usageFun((lines[0..1] ~ [null, description] ~ lines[1..$]).join("\n"));
552 		}
553 
554 		foreach (m; __traits(allMembers, Actions))
555 			static if (is(typeof(hasAttribute!(string, __traits(getMember, Actions, m)))))
556 			{
557 				enum name = m.toLower();
558 				if (name == action)
559 				{
560 					static if (hasAttribute!(string, __traits(getMember, Actions, m)))
561 					{
562 						enum description = getAttribute!(string, __traits(getMember, Actions, m));
563 						alias myUsageFun = descUsageFun!description;
564 					}
565 					else
566 						alias myUsageFun = usageFun;
567 
568 					auto args = [getProgramName(program) ~ " " ~ action] ~ actionArguments;
569 					return funopt!(__traits(getMember, Actions, m), config, myUsageFun)(args);
570 				}
571 			}
572 
573 		throw new GetOptException("Unknown action: " ~ action);
574 	}
575 
576 	static void myUsageFun(string usage) { usageFun(usage ~ genActionList!Actions()); }
577 
578 	const FunOptConfig myConfig = (){
579 		auto c = config;
580 		c.getoptConfig ~= std.getopt.config.stopOnFirstNonOption;
581 		return c;
582 	}();
583 	return funopt!(fun, myConfig, myUsageFun)(args);
584 }
585 
586 private string genActionList(alias Actions)()
587 {
588 	string result = "\nActions:\n";
589 
590 	size_t longestAction = 0;
591 	foreach (m; __traits(allMembers, Actions))
592 		static if (is(typeof(hasAttribute!(string, __traits(getMember, Actions, m)))))
593 			static if (hasAttribute!(string, __traits(getMember, Actions, m)))
594 				longestAction = max(longestAction, m.splitByCamelCase.join("-").length);
595 
596 	foreach (m; __traits(allMembers, Actions))
597 		static if (is(typeof(hasAttribute!(string, __traits(getMember, Actions, m)))))
598 			static if (hasAttribute!(string, __traits(getMember, Actions, m)))
599 			{
600 				enum name = m.splitByCamelCase.join("-").toLower();
601 				//__traits(comment, __traits(getMember, Actions, m)) // https://github.com/D-Programming-Language/dmd/pull/3531
602 				result ~= optionWrap(getAttribute!(string, __traits(getMember, Actions, m)), name, longestAction);
603 			}
604 
605 	return result;
606 }
607 
608 unittest
609 {
610 	struct Actions
611 	{
612 		@(`Perform action f1`)
613 		static void f1(bool verbose) {}
614 	}
615 
616 	funoptDispatch!Actions(["program", "f1", "--verbose"]);
617 
618 	assert(genActionList!Actions() == "
619 Actions:
620   f1  Perform action f1
621 ");
622 
623 	static string usage;
624 	static void usageFun(string _usage) { usage = _usage; }
625 	funoptDispatch!(Actions, FunOptConfig.init, usageFun)(["unittest", "f1", "--help"]);
626 	assert(usage == "Usage: unittest f1 [--verbose]
627 
628 Perform action f1
629 ", usage);
630 }