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.misc;
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_)
33 {
34 	enum type = type_;
35 	alias T = T_;
36 	enum description = description_;
37 	enum shorthand = shorthand_;
38 	enum placeholder = placeholder_;
39 
40 	T value;
41 	alias value this;
42 
43 	this(T value_)
44 	{
45 		value = value_;
46 	}
47 }
48 
49 /// An on/off switch (e.g. --verbose). Does not have a value, other than its presence.
50 template Switch(string description=null, char shorthand=0)
51 {
52 	alias Switch = OptionImpl!(OptionType.switch_, bool, description, shorthand, null);
53 }
54 
55 /// An option with a value (e.g. --tries N). The default placeholder depends on the type
56 /// (N for numbers, STR for strings).
57 template Option(T, string description=null, string placeholder=null, char shorthand=0)
58 {
59 	alias Option = OptionImpl!(OptionType.option, T, description, shorthand, placeholder);
60 }
61 
62 /// An ordered parameter.
63 template Parameter(T, string description=null)
64 {
65 	alias Parameter = OptionImpl!(OptionType.parameter, T, description, 0, null);
66 }
67 
68 private template OptionValueType(T)
69 {
70 	static if (is(T == OptionImpl!Args, Args...))
71 		alias OptionValueType = T.T;
72 	else
73 		alias OptionValueType = T;
74 }
75 
76 private OptionValueType!T* optionValue(T)(ref T option)
77 {
78 	static if (is(T == OptionImpl!Args, Args...))
79 		return &option.value;
80 	else
81 		return &option;
82 }
83 
84 private template isParameter(T)
85 {
86 	static if (is(T == OptionImpl!Args, Args...))
87 		enum isParameter = T.type == OptionType.parameter;
88 	else
89 	static if (is(T == bool))
90 		enum isParameter = false;
91 	else
92 		enum isParameter = true;
93 }
94 
95 private template isOptionArray(Param)
96 {
97 	alias T = OptionValueType!Param;
98 	static if (is(T == string))
99 		enum isOptionArray = false;
100 	else
101 	static if (is(T U : U[]))
102 		enum isOptionArray = true;
103 	else
104 		enum isOptionArray = false;
105 }
106 
107 private template optionShorthand(T)
108 {
109 	static if (is(T == OptionImpl!Args, Args...))
110 		enum optionShorthand = T.shorthand;
111 	else
112 		enum char optionShorthand = 0;
113 }
114 
115 private template optionDescription(T)
116 {
117 	static if (is(T == OptionImpl!Args, Args...))
118 		enum optionDescription = T.description;
119 	else
120 		enum string optionDescription = null;
121 }
122 
123 private enum bool optionHasDescription(T) = optionDescription!T !is null;
124 
125 private template optionPlaceholder(T)
126 {
127 	static if (is(T == OptionImpl!Args, Args...))
128 	{
129 		static if (T.placeholder.length)
130 			enum optionPlaceholder = T.placeholder;
131 		else
132 			enum optionPlaceholder = optionPlaceholder!(OptionValueType!T);
133 	}
134 	else
135 	static if (isOptionArray!T)
136 		enum optionPlaceholder = optionPlaceholder!(typeof(T.init[0]));
137 	else
138 	static if (is(T : real))
139 		enum optionPlaceholder = "N";
140 	else
141 	static if (is(T == string))
142 		enum optionPlaceholder = "STR";
143 	else
144 		enum optionPlaceholder = "X";
145 }
146 
147 struct FunOptConfig
148 {
149 	std.getopt.config[] getoptConfig;
150 	string usageHeader, usageFooter;
151 }
152 
153 /// Parse the given arguments according to FUN's parameters, and call FUN.
154 /// Throws GetOptException on errors.
155 auto funopt(alias FUN, FunOptConfig config = FunOptConfig.init)(string[] args)
156 {
157 	alias ParameterTypeTuple!FUN Params;
158 	Params values;
159 	enum names = [ParameterIdentifierTuple!FUN];
160 	alias defaults = ParameterDefaultValueTuple!FUN;
161 
162 	foreach (i, defaultValue; defaults)
163 	{
164 		static if (!is(defaultValue == void))
165 		{
166 			//values[i] = defaultValue;
167 			// https://issues.dlang.org/show_bug.cgi?id=13252
168 			values[i] = cast(OptionValueType!(Params[i])) defaultValue;
169 		}
170 	}
171 
172 	enum structFields =
173 		config.getoptConfig.length.iota.map!(n => "std.getopt.config config%d = std.getopt.config.%s;\n".format(n, config.getoptConfig[n])).join() ~
174 		Params.length.iota.map!(n => "string selector%d; OptionValueType!(Params[%d])* value%d;\n".format(n, n, n)).join();
175 
176 	static struct GetOptArgs { mixin(structFields); }
177 	GetOptArgs getOptArgs;
178 
179 	static string optionSelector(int i)()
180 	{
181 		string[] variants;
182 		auto shorthand = optionShorthand!(Params[i]);
183 		if (shorthand)
184 			variants ~= [shorthand];
185 		enum words = names[i].splitByCamelCase();
186 		variants ~= words.join().toLower();
187 		if (words.length > 1)
188 			variants ~= words.join("-").toLower();
189 		return variants.join("|");
190 	}
191 
192 	foreach (i, ref value; values)
193 	{
194 		enum selector = optionSelector!i();
195 		mixin("getOptArgs.selector%d = selector;".format(i));
196 		mixin("getOptArgs.value%d = optionValue(values[%d]);".format(i, i));
197 	}
198 
199 	auto origArgs = args;
200 	bool help;
201 
202 	getopt(args,
203 		std.getopt.config.bundling,
204 		getOptArgs.tupleof,
205 		"h|help", &help,
206 	);
207 
208 	void printUsage()
209 	{
210 		import std.stdio;
211 		stderr.writeln(config.usageHeader, getUsage!FUN(origArgs[0]), config.usageFooter);
212 	}
213 
214 	if (help)
215 	{
216 		printUsage();
217 		return cast(ReturnType!FUN)0;
218 	}
219 
220 	args = args[1..$];
221 
222 	foreach (i, ref value; values)
223 	{
224 		alias T = Params[i];
225 		static if (isParameter!T)
226 		{
227 			static if (is(T == string[]))
228 			{
229 				values[i] = args;
230 				args = null;
231 			}
232 			else
233 			{
234 				if (args.length)
235 				{
236 					values[i] = to!T(args[0]);
237 					args = args[1..$];
238 				}
239 				else
240 				{
241 					static if (is(defaults[i] == void))
242 					{
243 						// If the first argument is mandatory,
244 						// and no arguments were given, print usage.
245 						if (origArgs.length == 1)
246 							printUsage();
247 
248 						throw new GetOptException("No " ~ names[i] ~ " specified.");
249 					}
250 				}
251 			}
252 		}
253 	}
254 
255 	if (args.length)
256 		throw new GetOptException("Extra parameters specified: %(%s %)".format(args));
257 
258 	return FUN(values);
259 }
260 
261 unittest
262 {
263 	void f1(bool verbose, Option!int tries, string filename)
264 	{
265 		assert(verbose);
266 		assert(tries == 5);
267 		assert(filename == "filename.ext");
268 	}
269 	funopt!f1(["program", "--verbose", "--tries", "5", "filename.ext"]);
270 
271 	void f2(string a, Parameter!string b, string[] rest)
272 	{
273 		assert(a == "a");
274 		assert(b == "b");
275 		assert(rest == ["c", "d"]);
276 	}
277 	funopt!f2(["program", "a", "b", "c", "d"]);
278 
279 	void f3(Option!(string[], null, "DIR", 'x') excludeDir)
280 	{
281 		assert(excludeDir == ["a", "b", "c"]);
282 	}
283 	funopt!f3(["program", "--excludedir", "a", "--exclude-dir", "b", "-x", "c"]);
284 
285 	void f4(Option!string outputFile = "output.txt", string inputFile = "input.txt", string[] dataFiles = null)
286 	{
287 		assert(inputFile == "input.txt");
288 		assert(outputFile == "output.txt");
289 		assert(dataFiles == []);
290 	}
291 	funopt!f4(["program"]);
292 
293 	void f5(string input = null)
294 	{
295 		assert(input is null);
296 	}
297 	funopt!f5(["program"]);
298 }
299 
300 // ***************************************************************************
301 
302 private string getProgramName(string program)
303 {
304 	auto programName = program.baseName();
305 	version(Windows)
306 	{
307 		programName = programName.toLower();
308 		if (programName.extension == ".exe")
309 			programName = programName.stripExtension();
310 	}
311 
312 	return programName;
313 }
314 
315 private string getUsage(alias FUN)(string program)
316 {
317 	auto programName = getProgramName(program);
318 	enum formatString = getUsageFormatString!FUN();
319 	return formatString.format(programName);
320 }
321 
322 private string getUsageFormatString(alias FUN)()
323 {
324 	alias ParameterTypeTuple!FUN Params;
325 	enum names = [ParameterIdentifierTuple!FUN];
326 	alias defaults = ParameterDefaultValueTuple!FUN;
327 
328 	string result = "Usage: %s";
329 	enum haveNonParameters = !allSatisfy!(isParameter, Params);
330 	enum haveDescriptions = anySatisfy!(optionHasDescription, Params);
331 	static if (haveNonParameters && haveDescriptions)
332 		result ~= " [OPTION]...";
333 
334 	string getSwitchText(int i)()
335 	{
336 		alias Param = Params[i];
337 		string switchText = "--" ~ names[i].splitByCamelCase().join("-").toLower();
338 		static if (is(Param == OptionImpl!Args, Args...))
339 			static if (Param.type == OptionType.option)
340 				switchText ~= "=" ~ optionPlaceholder!Param;
341 		return switchText;
342 	}
343 
344 	foreach (i, Param; Params)
345 		{
346 			static if (isParameter!Param)
347 			{
348 				result ~= " ";
349 				static if (!is(defaults[i] == void))
350 					result ~= "[";
351 				result ~= toUpper(names[i].splitByCamelCase().join("-"));
352 				static if (!is(defaults[i] == void))
353 					result ~= "]";
354 			}
355 			else
356 			{
357 				static if (optionHasDescription!Param)
358 					continue;
359 				else
360 					result ~= " [" ~ getSwitchText!i() ~ "]";
361 			}
362 			static if (isOptionArray!Param)
363 				result ~= "...";
364 		}
365 
366 	result ~= "\n";
367 
368 	static if (haveDescriptions)
369 	{
370 		enum haveShorthands = anySatisfy!(optionShorthand, Params);
371 		string[Params.length] selectors;
372 		size_t longestSelector;
373 
374 		foreach (i, Param; Params)
375 			static if (optionHasDescription!Param)
376 			{
377 				string switchText = getSwitchText!i();
378 				if (haveShorthands)
379 				{
380 					auto c = optionShorthand!Param;
381 					if (c)
382 						selectors[i] = "-%s, %s".format(c, switchText);
383 					else
384 						selectors[i] = "    %s".format(switchText);
385 				}
386 				else
387 					selectors[i] = switchText;
388 				longestSelector = max(longestSelector, selectors[i].length);
389 			}
390 
391 		result ~= "\nOptions:\n";
392 		foreach (i, Param; Params)
393 			static if (optionHasDescription!Param)
394 			{
395 				result ~= wrap(
396 					optionDescription!Param,
397 					79,
398 					"  %-*s  ".format(longestSelector, selectors[i]),
399 					" ".replicate(2 + longestSelector + 2)
400 				);
401 			}
402 	}
403 
404 	return result;
405 }
406 
407 unittest
408 {
409 	void f1(
410 		Switch!("Enable verbose logging", 'v') verbose,
411 		Option!(int, "Number of tries") tries,
412 		Option!(int, "Seconds to wait each try", "SECS") timeout,
413 		string filename,
414 		string output = "default",
415 		string[] extraFiles = null
416 	)
417 	{}
418 
419 	auto usage = getUsage!f1("program");
420 	assert(usage ==
421 "Usage: program [OPTION]... FILENAME [OUTPUT] [EXTRA-FILES]...
422 
423 Options:
424   -v, --verbose       Enable verbose logging
425       --tries=N       Number of tries
426       --timeout=SECS  Seconds to wait each try
427 ", usage);
428 
429 	void f2(
430 		bool verbose,
431 		Option!(string[]) extraFile,
432 		string filename,
433 		string output = "default"
434 	)
435 	{}
436 
437 	usage = getUsage!f2("program");
438 	assert(usage ==
439 "Usage: program [--verbose] [--extra-file=STR]... FILENAME [OUTPUT]
440 ", usage);
441 }
442 
443 // ***************************************************************************
444 
445 /// Dispatch the command line to a type's static methods, according to the
446 /// first parameter on the given command line (the "action").
447 /// String UDAs are used as usage documentation for generating --help output
448 /// (or when no action is specified).
449 auto funoptDispatch(alias Actions, FunOptConfig config = FunOptConfig.init)(string[] args)
450 {
451 	string program = args[0];
452 
453 	auto fun(string action, string[] actionArguments = [])
454 	{
455 		foreach (m; __traits(allMembers, Actions))
456 		{
457 			enum name = m.toLower();
458 			if (name == action)
459 			{
460 				auto args = [getProgramName(program) ~ " " ~ action] ~ actionArguments;
461 				return funopt!(__traits(getMember, Actions, m), config)(args);
462 			}
463 		}
464 
465 		throw new GetOptException("Unknown action: " ~ action);
466 	}
467 
468 	enum actionList = genActionList!Actions();
469 
470 	const FunOptConfig myConfig = (){
471 		auto c = config;
472 		c.getoptConfig ~= std.getopt.config.stopOnFirstNonOption;
473 		c.usageFooter = actionList ~ c.usageFooter;
474 		return c;
475 	}();
476 	return funopt!(fun, myConfig)(args);
477 }
478 
479 private string genActionList(alias Actions)()
480 {
481 	string result = "\nActions:\n";
482 
483 	size_t longestAction = 0;
484 	foreach (m; __traits(allMembers, Actions))
485 		static if (hasAttribute!(string, __traits(getMember, Actions, m)))
486 			longestAction = max(longestAction, m.length);
487 
488 	foreach (m; __traits(allMembers, Actions))
489 		static if (hasAttribute!(string, __traits(getMember, Actions, m)))
490 		{
491 			enum name = m.toLower();
492 			result ~= wrap(
493 				//__traits(comment, __traits(getMember, Actions, m)), // https://github.com/D-Programming-Language/dmd/pull/3531
494 				getAttribute!(string, __traits(getMember, Actions, m)),
495 				79,
496 				"  %-*s  ".format(longestAction, name),
497 				" ".replicate(2 + longestAction + 2)
498 			);
499 		}
500 
501 	return result;
502 }
503 
504 unittest
505 {
506 	struct Actions
507 	{
508 		@(`Perform action f1`)
509 		static void f1(bool verbose) {}
510 	}
511 
512 	funoptDispatch!Actions(["program", "f1", "--verbose"]);
513 
514 	assert(genActionList!Actions() == "
515 Actions:
516   f1  Perform action f1
517 ");
518 }