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 && !optionHasDescription!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 
503 // ***************************************************************************
504 
505 /// Dispatch the command line to a type's static methods, according to the
506 /// first parameter on the given command line (the "action").
507 /// String UDAs are used as usage documentation for generating --help output
508 /// (or when no action is specified).
509 auto funoptDispatch(alias Actions, FunOptConfig config = FunOptConfig.init, alias usageFun = defaultUsageFun)(string[] args)
510 {
511 	string program = args[0];
512 
513 	auto fun(string action, string[] actionArguments = [])
514 	{
515 		action = action.replace("-", "");
516 
517 		static void descUsageFun(string description)(string usage)
518 		{
519 			auto lines = usage.split("\n");
520 			usageFun((lines[0..1] ~ [null, description] ~ lines[1..$]).join("\n"));
521 		}
522 
523 		foreach (m; __traits(allMembers, Actions))
524 		{
525 			enum name = m.toLower();
526 			if (name == action)
527 			{
528 				static if (hasAttribute!(string, __traits(getMember, Actions, m)))
529 				{
530 					enum description = getAttribute!(string, __traits(getMember, Actions, m));
531 					alias myUsageFun = descUsageFun!description;
532 				}
533 				else
534 					alias myUsageFun = usageFun;
535 
536 				auto args = [getProgramName(program) ~ " " ~ action] ~ actionArguments;
537 				return funopt!(__traits(getMember, Actions, m), config, myUsageFun)(args);
538 			}
539 		}
540 
541 		throw new GetOptException("Unknown action: " ~ action);
542 	}
543 
544 	static void myUsageFun(string usage) { usageFun(usage ~ genActionList!Actions()); }
545 
546 	const FunOptConfig myConfig = (){
547 		auto c = config;
548 		c.getoptConfig ~= std.getopt.config.stopOnFirstNonOption;
549 		return c;
550 	}();
551 	return funopt!(fun, myConfig, myUsageFun)(args);
552 }
553 
554 private string genActionList(alias Actions)()
555 {
556 	string result = "\nActions:\n";
557 
558 	size_t longestAction = 0;
559 	foreach (m; __traits(allMembers, Actions))
560 		static if (hasAttribute!(string, __traits(getMember, Actions, m)))
561 			longestAction = max(longestAction, m.splitByCamelCase.join("-").length);
562 
563 	foreach (m; __traits(allMembers, Actions))
564 		static if (hasAttribute!(string, __traits(getMember, Actions, m)))
565 		{
566 			enum name = m.splitByCamelCase.join("-").toLower();
567 			//__traits(comment, __traits(getMember, Actions, m)) // https://github.com/D-Programming-Language/dmd/pull/3531
568 			result ~= optionWrap(getAttribute!(string, __traits(getMember, Actions, m)), name, longestAction);
569 		}
570 
571 	return result;
572 }
573 
574 unittest
575 {
576 	struct Actions
577 	{
578 		@(`Perform action f1`)
579 		static void f1(bool verbose) {}
580 	}
581 
582 	funoptDispatch!Actions(["program", "f1", "--verbose"]);
583 
584 	assert(genActionList!Actions() == "
585 Actions:
586   f1  Perform action f1
587 ");
588 
589 	static string usage;
590 	static void usageFun(string _usage) { usage = _usage; }
591 	funoptDispatch!(Actions, FunOptConfig.init, usageFun)(["unittest", "f1", "--help"]);
592 	assert(usage == "Usage: unittest f1 [--verbose]
593 
594 Perform action f1
595 ", usage);
596 }