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(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 = 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)
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 			{
408 				static if (optionHasDescription!Param)
409 					continue;
410 				else
411 					result ~= " [" ~ getSwitchText!i() ~ "]";
412 			}
413 			static if (isOptionArray!Param)
414 				result ~= "...";
415 		}
416 
417 	result ~= "\n";
418 
419 	static if (haveDescriptions)
420 	{
421 		enum haveShorthands = anySatisfy!(optionShorthand, Params);
422 		string[Params.length] selectors;
423 		size_t longestSelector;
424 
425 		foreach (i, Param; Params)
426 			static if (optionHasDescription!Param)
427 			{
428 				string switchText = getSwitchText!i();
429 				if (haveShorthands)
430 				{
431 					auto c = optionShorthand!Param;
432 					if (c)
433 						selectors[i] = "-%s, %s".format(c, switchText);
434 					else
435 						selectors[i] = "    %s".format(switchText);
436 				}
437 				else
438 					selectors[i] = switchText;
439 				longestSelector = max(longestSelector, selectors[i].length);
440 			}
441 
442 		result ~= "\nOptions:\n";
443 		foreach (i, Param; Params)
444 			static if (optionHasDescription!Param)
445 				result ~= optionWrap(optionDescription!Param, selectors[i], longestSelector);
446 	}
447 
448 	return result;
449 }
450 
451 string optionWrap(string text, string firstIndent, size_t indentWidth)
452 {
453 	enum width = 79;
454 	auto padding = " ".replicate(2 + indentWidth + 2);
455 	auto paragraphs = text.split("\n");
456 	auto result = wrap(
457 		paragraphs[0],
458 		width,
459 		"  %-*s  ".format(indentWidth, firstIndent),
460 		padding
461 	);
462 	result ~= paragraphs[1..$].map!(p => wrap(p, width, padding, padding)).join();
463 	return result;
464 }
465 
466 unittest
467 {
468 	void f1(
469 		Switch!("Enable verbose logging", 'v') verbose,
470 		Option!(int, "Number of tries") tries,
471 		Option!(int, "Seconds to\nwait each try", "SECS", 0, "timeout") t,
472 		string filename,
473 		string output = "default",
474 		string[] extraFiles = null
475 	)
476 	{}
477 
478 	auto usage = getUsage!f1("program");
479 	assert(usage ==
480 "Usage: program [OPTION]... FILENAME [OUTPUT] [EXTRA-FILES]...
481 
482 Options:
483   -v, --verbose       Enable verbose logging
484       --tries=N       Number of tries
485       --timeout=SECS  Seconds to
486                       wait each try
487 ", usage);
488 
489 	void f2(
490 		bool verbose,
491 		Option!(string[]) extraFile,
492 		string filename,
493 		string output = "default",
494 	)
495 	{}
496 
497 	usage = getUsage!f2("program");
498 	assert(usage ==
499 "Usage: program [--verbose] [--extra-file=STR]... FILENAME [OUTPUT]
500 ", usage);
501 
502 	void f3(
503 		Parameter!(string[]) args = null,
504 	)
505 	{}
506 
507 	usage = getUsage!f3("program");
508 	assert(usage ==
509 "Usage: program [ARGS]...
510 ", usage);
511 
512 	void f4(
513 		Parameter!(string[], "The program arguments.") args = null,
514 	)
515 	{}
516 
517 	usage = getUsage!f4("program");
518 	assert(usage ==
519 "Usage: program [ARGS]...
520 
521 Options:
522   ARGS  The program arguments.
523 ", usage);
524 }
525 
526 // ***************************************************************************
527 
528 /// Dispatch the command line to a type's static methods, according to the
529 /// first parameter on the given command line (the "action").
530 /// String UDAs are used as usage documentation for generating --help output
531 /// (or when no action is specified).
532 auto funoptDispatch(alias Actions, FunOptConfig config = FunOptConfig.init, alias usageFun = defaultUsageFun)(string[] args)
533 {
534 	string program = args[0];
535 
536 	auto fun(string action, string[] actionArguments = [])
537 	{
538 		action = action.replace("-", "");
539 
540 		static void descUsageFun(string description)(string usage)
541 		{
542 			auto lines = usage.split("\n");
543 			usageFun((lines[0..1] ~ [null, description] ~ lines[1..$]).join("\n"));
544 		}
545 
546 		foreach (m; __traits(allMembers, Actions))
547 			static if (is(typeof(hasAttribute!(string, __traits(getMember, Actions, m)))))
548 			{
549 				enum name = m.toLower();
550 				if (name == action)
551 				{
552 					static if (hasAttribute!(string, __traits(getMember, Actions, m)))
553 					{
554 						enum description = getAttribute!(string, __traits(getMember, Actions, m));
555 						alias myUsageFun = descUsageFun!description;
556 					}
557 					else
558 						alias myUsageFun = usageFun;
559 
560 					auto args = [getProgramName(program) ~ " " ~ action] ~ actionArguments;
561 					return funopt!(__traits(getMember, Actions, m), config, myUsageFun)(args);
562 				}
563 			}
564 
565 		throw new GetOptException("Unknown action: " ~ action);
566 	}
567 
568 	static void myUsageFun(string usage) { usageFun(usage ~ genActionList!Actions()); }
569 
570 	const FunOptConfig myConfig = (){
571 		auto c = config;
572 		c.getoptConfig ~= std.getopt.config.stopOnFirstNonOption;
573 		return c;
574 	}();
575 	return funopt!(fun, myConfig, myUsageFun)(args);
576 }
577 
578 private string genActionList(alias Actions)()
579 {
580 	string result = "\nActions:\n";
581 
582 	size_t longestAction = 0;
583 	foreach (m; __traits(allMembers, Actions))
584 		static if (is(typeof(hasAttribute!(string, __traits(getMember, Actions, m)))))
585 			static if (hasAttribute!(string, __traits(getMember, Actions, m)))
586 				longestAction = max(longestAction, m.splitByCamelCase.join("-").length);
587 
588 	foreach (m; __traits(allMembers, Actions))
589 		static if (is(typeof(hasAttribute!(string, __traits(getMember, Actions, m)))))
590 			static if (hasAttribute!(string, __traits(getMember, Actions, m)))
591 			{
592 				enum name = m.splitByCamelCase.join("-").toLower();
593 				//__traits(comment, __traits(getMember, Actions, m)) // https://github.com/D-Programming-Language/dmd/pull/3531
594 				result ~= optionWrap(getAttribute!(string, __traits(getMember, Actions, m)), name, longestAction);
595 			}
596 
597 	return result;
598 }
599 
600 unittest
601 {
602 	struct Actions
603 	{
604 		@(`Perform action f1`)
605 		static void f1(bool verbose) {}
606 	}
607 
608 	funoptDispatch!Actions(["program", "f1", "--verbose"]);
609 
610 	assert(genActionList!Actions() == "
611 Actions:
612   f1  Perform action f1
613 ");
614 
615 	static string usage;
616 	static void usageFun(string _usage) { usage = _usage; }
617 	funoptDispatch!(Actions, FunOptConfig.init, usageFun)(["unittest", "f1", "--help"]);
618 	assert(usage == "Usage: unittest f1 [--verbose]
619 
620 Perform action f1
621 ", usage);
622 }