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