1 // SemiTwist Library
2 // Written in the D programming language.
3 
4 module semitwist.cmdlineparser;
5 
6 import std.math;
7 import std.string;
8 import std.conv;
9 import std.stdio;
10 
11 public import semitwist.refbox;
12 import semitwist.util.all;
13 
14 //TODO: This module's API needs a serious overhaul.
15 
16 //TODO: Add "switch A implies switches B and C"
17 //TODO: Add in some good ideas from the cmd parser in tango scrapple
18 
19 //TODO: Convert the following sample code into an actual sample app
20 /**
21 ----- THIS IS PROBABLY OUTDATED -----
22 Usage:
23 
24 void main(string[] args)
25 {
26 	bool help;
27 	bool detailhelp;
28 	int myInt = 2; // Default value == 2
29 	bool myBool;   // Default value == bool.init (ie, false)
30 	string myStr;  // Default value == (string).init (ie, "")
31 
32 	auto cmd = new CmdLineParser();
33 	mixin(defineArg!(cmd, help,       "help",       "Displays a help summary and exits" ));
34 	mixin(defineArg!(cmd, detailhelp, "detailhelp", "Displays a detailed help message and exits" ));
35 	mixin(defineArg!(cmd, myInt,  "num",  "An integer"));
36 	mixin(defineArg!(cmd, myBool, "flag", "A flag"));
37 	mixin(defineArg!(cmd, myStr,  "str",  "A string"));
38 	
39 	if(!cmd.parse(args) || help)
40 	{
41 		Stdout.format("{}", cmd.getUsage());
42 		return;
43 	}
44 	if(detailhelp)
45 	{
46 		Stdout.format("{}", cmd.getDetailedUsage());
47 		return;
48 	}
49 
50 	Stdout.formatln("num:  {}", myInt);
51 	Stdout.formatln("flag: {}", myBool);
52 	Stdout.formatln("str:  {}", myStr);
53 }
54 
55 Sample Command Lines (All these are equivalent):
56 > myApp.exe /num:5 /flag /str:blah
57 > myApp.exe -flag:true -str:blah -num=5
58 > myApp.exe --num=5 /flag+ "-str:blah"
59 num:  5
60 flag: true
61 str:  blah
62 
63 > myApp.exe "/str:Hello World"
64 num:  2
65 flag: false
66 str:  Hello World
67 
68 > myApp.exe /foo
69 Unknown switch: "/foo"
70 Switches: (prefixes can be '/', '-' or '--')
71   -help               Displays a help summary and exits
72   -detailhelp         Displays a detailed help message and exits
73   -num:<int>          An integer (default: 2)
74   -flag               A flag
75   -str:<string>       A string
76   
77 */
78 
79 enum ArgFlag
80 {
81 	Optional   = 0b0000_0000,
82 	Required   = 0b0000_0001,
83 	//Unique = 0b0000_0100,
84 	ToLower    = 0b0000_1000, // If arg is string, the value gets converted to all lower-case (for case-insensitivity)
85 	Advanced   = 0b0001_0000,
86 }
87 
88 template defineArg(alias cmdLineParser, string name, alias var, int flags = cast(int)ArgFlag.Optional, string desc = "")
89 //template defineArg(alias cmdLineParser, string name, alias var, ArgFlag flags = ArgFlag.Optional, string desc = "")
90 {
91 	//TODO: Is there a better way to do this? Ex. "static if(typeof(var) !contained_in listOfSupportedTypes)"
92 	static if(!is(typeof(var) == int   ) && !is(typeof(var) == int[]   ) && 
93 	          !is(typeof(var) == bool  ) && !is(typeof(var) == bool[]  ) && 
94 			  !is(typeof(var) == string) && !is(typeof(var) == string[]) )
95 	{
96 		static assert(false, `Attempted to pass variable '`~var.stringof~`' of type '`~typeof(var).stringof~`' to defineArg's 'var' param.`"\n"
97 		                     `(The type must be one of, or an array of, one of the following: 'int' 'bool' 'string')`);
98 	}
99 	else
100 	{
101 		enum defineArg = "\n"~
102 			"auto _cmdarg_refbox_"~name~" = new "~nameof!(RefBox)~"!("~typeof(var).stringof~")(&"~var.stringof~");\n"~
103 			"auto _cmdarg_"~name~" = new Arg(_cmdarg_refbox_"~name~`, "`~name~`", `~desc.stringof~`);`~"\n"~
104 			cmdLineParser.stringof~".addArg(_cmdarg_"~name~", cast(ArgFlag)("~flags.stringof~"));\n";
105 	}
106 }
107 
108 template setArgAllowableValues(string name, allowableValues...)
109 {
110 	enum setArgAllowableValues =
111 		typeof(allowableValues[0]).stringof~"[] _cmdarg_allowablevals_"~name~";\n"
112 		~_setArgAllowableValues!(name, allowableValues)
113 		~"_cmdarg_"~name~".setAllowableValues(_cmdarg_allowablevals_"~name~");\n";
114 }
115 
116 private template _setArgAllowableValues(string name, allowableValues...)
117 {
118 	static if(allowableValues.length == 0)
119 		enum _setArgAllowableValues = "";
120 	else
121 		enum _setArgAllowableValues =
122 			"_cmdarg_allowablevals_"~name~" ~= "~allowableValues[0].stringof~";\n"
123 			~ _setArgAllowableValues!(name, allowableValues[1..$]);
124 }
125 
126 //TODO? Add float, double, byte, short, long, and unsigned of each.
127 //TODO: For numeric types, make sure provided values can fit in the type. (Using "to!()"?)
128 //TODO: Think about way to (or the need to) prevent adding
129 //      the same Arg instance to multiple Parsers.
130 
131 class Arg
132 {
133 	string name;
134 	string altName;
135 	string desc;
136 
137 	bool isSwitchless  = false;
138 	bool isRequired    = false;
139 	bool arrayUnique   = false;
140 	bool toLower       = false;
141 	bool isAdvanced    = false;
142 	
143 	private Object value;
144 	private Object defaultValue;
145 	private Object[] allowableValues;
146 	
147 	bool isSet = false;
148 	
149 	this(Object value, string name, string desc="")
150 	{
151 		mixin(initMember("value", "name", "desc"));
152 		ensureValid();
153 	}
154 	
155 	private void genDefaultValue()
156 	{
157 		if(!isRequired)
158 		{
159 			mixin(dupRefBox!(value, "val", defaultValue));
160 		}
161 	}
162 	
163 	// Note: AllowableValues are ignored for bool and bool[]
164 	private void setAllowableValues(T)(T[] allowableValues)
165 	{
166 		this.allowableValues.length = 0;
167 		foreach(T val; allowableValues)
168 		{
169 			auto box = new RefBox!(T)();
170 			box = val;
171 			this.allowableValues ~= box;
172 		}
173 	}
174 	
175 	void ensureValid()
176 	{
177 		//TODO: arrayMultiple and arrayUnique cannot both be set
178 		//TODO: ensure each of allowableValues is the same type as value
179 		//TODO: enforce allowableValues on defaultValue
180 		//TODO: reflect allowableValues in generated help
181 		
182 		if(!isKnownRefBox!(value))
183 		{
184 			throw new Exception("Param to Arg contructor must be "~RefBox.stringof~", where T is int, bool or string or an array of such types.");
185 		}
186 		
187 		void ensureValidName(string name)
188 		{
189 			if(!CmdLineParser.isValidArgName(name))
190 				throw new Exception(`Tried to define an invalid arg name: "%s". Arg names must be "[a-zA-Z0-9_?]*"`.format(name));
191 		}
192 		ensureValidName(name);
193 		ensureValidName(altName);
194 	}
195 }
196 
197 class CmdLineParser
198 {
199 	private Arg[] args;
200 	private Arg[string] argLookup;
201 	
202 	private bool switchlessArgExists=false;
203 	private size_t switchlessArg;
204 	
205 	mixin(getter!(bool, "success"));
206 	mixin(getter!(string, "errorMsg"));
207 
208 	private enum Prefix
209 	{
210 		Invalid, DoubleDash, SingleDash, Slash
211 	}
212 	
213 	enum ParseArgResult
214 	{
215 		Done, NotFound, Error
216 	}
217 	
218 	static bool isValidArgName(string name)
219 	{
220 		foreach(char c; name)
221 		{
222 			if(!inPattern(c, "a-zA-Z0-9") && c != '_' && c != '?')
223 				return false;
224 		}
225 		return true;
226 	}
227 	
228 	private void ensureValid()
229 	{
230 		foreach(Arg arg; args)
231 		{
232 			arg.ensureValid();
233 		}
234 	}
235 	
236 	private void populateLookup()
237 	{
238 		foreach(Arg arg; args)
239 		{
240 			addToArgLookup(arg.name, arg);
241 			
242 			if(arg.altName != "")
243 				addToArgLookup(arg.altName, arg);
244 		}
245 	}
246 
247 	private void genDefaultValues()
248 	{
249 		foreach(Arg arg; args)
250 		{
251 			arg.genDefaultValue();
252 		}
253 	}
254 
255 	public void addArg(Arg arg, ArgFlag flags = ArgFlag.Optional)
256 	{
257 		args ~= arg;
258 
259 		bool isSwitchless = arg.name == "";
260 		bool isRequired   = ((flags & ArgFlag.Required)   != 0);
261 		bool toLower      = ((flags & ArgFlag.ToLower)    != 0);
262 		bool isAdvanced   = ((flags & ArgFlag.Advanced)   != 0);
263 		
264 		mixin(initMemberTo("arg", "isRequired", "toLower", "isAdvanced"));
265 		
266 		if(isSwitchless)
267 		{
268 			if(switchlessArgExists)
269 				args[switchlessArg].isSwitchless = false;
270 			
271 			switchlessArgExists = true;
272 			switchlessArg = args.length-1;
273 			arg.isSwitchless = true;
274 		}
275 	}
276 	
277 	private void addToArgLookup(string name, Arg argDef)
278 	{
279 		if(name in argLookup)
280 			throw new Exception(`Argument name "%s" defined more than once.`.format(name));
281 
282 		argLookup[name] = argDef;
283 	}
284 	
285 	private void splitArg(string fullArg, out Prefix prefix, out string name, out string suffix)
286 	{
287 		string argNoPrefix;
288 
289 		// Get prefix
290 		if(fullArg.length > 2 && fullArg[0..2] == "--")
291 		{
292 			argNoPrefix = fullArg[2..$];
293 			prefix = Prefix.DoubleDash;
294 		}
295 		else if(fullArg.length > 1)
296 		{
297 			argNoPrefix = fullArg[1..$];
298 			
299 			if(fullArg[0] == '-')
300 				prefix = Prefix.SingleDash;
301 			else if(fullArg[0] == '/')
302 				prefix = Prefix.Slash;
303 			else
304 			{
305 				prefix = Prefix.Invalid;
306 				argNoPrefix = fullArg;
307 			}
308 		}
309 		
310 		// Get suffix and arg name
311 		version(GNU)
312 		{
313 			auto tmp = [locate(argNoPrefix, ':'), locate(argNoPrefix, '+')];
314 			tmp ~= locate(argNoPrefix, '-');
315 			auto suffixIndex = reduce!"a<b?a:b"(tmp);
316 		}
317 		else
318 		{
319 			auto suffixIndex = reduce!"a<b?a:b"( [
320 				locate(argNoPrefix, ':'),
321 				locate(argNoPrefix, '+'),
322 				locate(argNoPrefix, '-')
323 			] );
324 		}
325 		name = argNoPrefix[0..suffixIndex];
326 		suffix = suffixIndex < argNoPrefix.length ?
327 				 argNoPrefix[suffixIndex..$] : "";
328 	}
329 	
330 	//TODO: Detect and error when numerical arg is passed an out-of-range value
331 	private ParseArgResult parseArg(string cmdArg, string cmdName, string suffix)
332 	{
333 		ParseArgResult ret = ParseArgResult.Error;
334 
335 		void HandleMalformedArgument()
336 		{
337 			_errorMsg ~= `Invalid value: "%s"`.formatln(cmdArg);
338 			ret = ParseArgResult.Error;
339 		}
340 		
341 		if(cmdName in argLookup)
342 		{
343 			auto argDef = argLookup[cmdName];
344 			
345 			// For some reason, unbox can't see Arg's private member "value"
346 			auto argDefValue = argDef.value;
347 			mixin(unbox!(argDefValue, "val"));
348 
349 			ret = ParseArgResult.Done;
350 			if(argDef.isSet && (valAsBool || valAsStr || valAsInt))
351 			{
352 				_errorMsg ~= `Switch given twice: "%s"`.formatln(cmdArg);
353 				ret = ParseArgResult.Error;
354 			}
355 			else if(valAsBool || valAsBools)
356 			{
357 				bool val;
358 				bool isMalformed=false;
359 				switch(suffix)
360 				{
361 				case "":
362 				case "+":
363 				case ":+":
364 				case ":true":
365 					val = true;
366 					break;
367 				case "-":
368 				case ":-":
369 				case ":false":
370 					val = false;
371 					break;
372 				default:
373 					HandleMalformedArgument();
374 					isMalformed = true;
375 					break;
376 				}
377 				
378 				if(!isMalformed)
379 				{
380 					if(valAsBool)
381 						valAsBool = val;
382 					else
383 						valAsBools = valAsBools() ~ val;
384 				}
385 			}
386 			else if(valAsStr || valAsStrs)
387 			{
388 				string val;
389 				if(suffix.length > 1 && suffix[0] == ':')
390 				{
391 					val = strip(suffix[1..$]);
392 
393 					if(argDef.toLower)
394 						val = val.toLower();
395 					
396 					//TODO: DRY this
397 					if(argDef.allowableValues.length > 0)
398 					{
399 						bool matchFound=false;
400 						foreach(Object allowedObj; argDef.allowableValues)
401 						{
402 							mixin(unbox!(allowedObj, "allowedVal"));
403 							if(val == allowedValAsStr)
404 							{
405 								matchFound = true;
406 								break;
407 							}
408 						}
409 						if(!matchFound)
410 							HandleMalformedArgument();
411 					}
412 
413 					if(valAsStr)
414 						valAsStr = val;
415 					else
416 						valAsStrs = valAsStrs() ~ val;
417 				}
418 				else
419 					HandleMalformedArgument();
420 			}
421 			else if(valAsInt || valAsInts)
422 			{
423 				int val;
424 				size_t parseAte;
425 				if(suffix.length > 1 && suffix[0] == ':')
426 				{
427 					string trimmedSuffix = strip(suffix[1..$]);
428 					auto copyTrimmedSuffix = trimmedSuffix;
429 					val = std.conv.parse!int(copyTrimmedSuffix);
430 					parseAte = trimmedSuffix.length - copyTrimmedSuffix.length;
431 					//val = cast(int)convInt.parse(trimmedSuffix, 0, &parseAte);
432 					if(parseAte == trimmedSuffix.length)
433 					{
434 						//TODO: DRY this
435 						if(argDef.allowableValues.length > 0)
436 						{
437 							bool matchFound=false;
438 							foreach(Object allowedObj; argDef.allowableValues)
439 							{
440 								mixin(unbox!(allowedObj, "allowedVal"));
441 								if(val == allowedValAsInt)
442 								{
443 									matchFound = true;
444 									break;
445 								}
446 							}
447 							if(!matchFound)
448 								HandleMalformedArgument();
449 						}
450 
451 						if(valAsInt)
452 							valAsInt = val;
453 						else
454 							valAsInts = valAsInts() ~ val;
455 					}
456 					else
457 						HandleMalformedArgument();
458 				}
459 				else
460 					HandleMalformedArgument();
461 			}
462 			else
463 				throw new Exception("Internal Error: Failed to process an Arg.value type that hasn't been set as unsupported.");
464 
465 			argDef.isSet = true;
466 		}
467 		else
468 		{
469 			_errorMsg ~= `Unknown switch: "%s"`.formatln(cmdArg);
470 			ret = ParseArgResult.NotFound;
471 		}
472 		
473 		return ret;
474 	}
475 
476 	//TODO: response file
477 
478 	public bool parse(string[] args)
479 	{
480 		bool error=false;
481 		
482 		ensureValid();
483 		populateLookup();
484 		genDefaultValues();
485 		
486 		foreach(string argStr; args[1..$])
487 		{
488 			string suffix;
489 			string argName;
490 			Prefix prefix;
491 			
492 			splitArg(argStr, prefix, argName, suffix);
493 			if(prefix == Prefix.Invalid)
494 			{
495 				if(switchlessArgExists)
496 				{
497 					argName = this.args[switchlessArg].name;
498 					suffix = ":"~argStr;
499 				}
500 				else
501 				{
502 					_errorMsg ~= `Unexpected value: "%s"`.formatln(argStr);
503 					error = true;
504 					continue;
505 				}
506 			}
507 			//mixin(traceVal!("argStr ", "prefix ", "argName", "suffix "));
508 			
509 			auto result = parseArg(argStr, argName, suffix);
510 			switch(result)
511 			{
512 			case ParseArgResult.Done:
513 				continue;
514 				
515 			case ParseArgResult.Error:
516 			case ParseArgResult.NotFound:
517 				error = true;
518 				break;
519 				
520 			default:
521 				throw new Exception("Unexpected ParseArgResult: (%s)".format(result));
522 			}
523 		}
524 		
525 		if(!verify())
526 			error = true;
527 		
528 		_success = !error;
529 		return _success;
530 	}
531 	
532 	private bool verify()
533 	{
534 		bool error=false;
535 		
536 		foreach(Arg arg; this.args)
537 		{
538 			if(arg.isRequired && !arg.isSet)
539 			{
540 				_errorMsg ~=
541 					`Missing switch: %s (%s)`
542 						.formatln(
543 							arg.name=="" ? "<"~getArgTypeName(arg)~">" : arg.name,
544 							arg.desc
545 						);
546 				error = true;
547 			}
548 		}
549 		
550 		return !error;
551 	}
552 	
553 	//TODO: Make function to get the maximum length of the arg names
554 
555 	private string switchTypesMsg =
556 `Switch types:
557   flag (default):
558     Set s to true: -s -s+ -s:true
559     Set s to false: -s- -s:false
560     Default value: false (unless otherwise noted)
561 
562   text:
563     Set s to "Hello": -s:Hello
564     Default value: "" (unless otherwise noted)
565     Case-sensitive unless otherwise noted.
566 
567   num:
568     Set s to 3: -s:3
569     Default value: 0 (unless otherwise noted)
570   
571   If "[]" appears at the end of the type,
572   this means multiple values are accepted.
573   Example:
574     -s:<text[]>: -s:file1 -s:file2 -s:anotherfile
575 `;
576 
577 	string getArgTypeName(Arg arg)
578 	{
579 		string typeName = getRefBoxTypeName(arg.value);
580 		return
581 			(typeName == "string"  )? "text"   :
582 			(typeName == "string[]")? "text[]" :
583 			(typeName == "char[]"  )? "text"   :
584 			(typeName == "char[][]")? "text[]" :
585 			(typeName == "bool"    )? "flag"   :
586 			(typeName == "bool[]"  )? "flag[]" :
587 			(typeName == "int"     )? "num"    :
588 			(typeName == "int[]"   )? "num[]"  :
589 			typeName;
590 	}
591 	
592 	//TODO: Fix word wrapping
593 	string getUsage(int nameColumnWidth=20)
594 	{
595 		string ret;
596 		string indent = "  ";
597 		string basicArgStr;
598 		string advancedArgStr;
599 		
600 		ret ~=
601 			"Switches:\n"~
602 			"(Prefixes can be '/', '-' or '--')\n"~
603 			"('[]' means multiple switches are accepted)\n"; //TODO: Only show this line if such a switch exists
604 
605 		foreach(Arg arg; args)
606 		{
607 			string* argStr = arg.isAdvanced? &advancedArgStr : &basicArgStr;
608 			
609 			// For some reason, unbox can't see Arg's private member "defaultValue"
610 			auto argDefaultValue = arg.defaultValue;
611 			mixin(unbox!(argDefaultValue, "val"));
612 
613 			string defaultVal;
614 			if(valAsInt)
615 				defaultVal = "%s".format(valAsInt());
616 			else if(valAsBool)
617 				defaultVal = valAsBool() ? "true" : "";
618 			else if(valAsStr)
619 				defaultVal = valAsStr() == "" ? "" : `"%s"`.format(valAsStr());
620 			
621 			string defaultValStr = defaultVal == "" ?
622 				"" : " (default: %s)".format(defaultVal);
623 				
624 			string requiredStr = arg.isRequired ?
625 				"(Required) " : "";
626 			
627 			string argType = "<"~getArgTypeName(arg)~">";
628 			string argSuffix = valAsBool ? "" : (":"~argType);
629 
630 			string argName;
631 			if(arg.name=="")
632 				argName = argType;
633 			else
634 				argName = "-"~arg.name~argSuffix;
635 			if(arg.altName != "")
636 				argName ~= ", -"~arg.altName~argSuffix;
637 	
638 			string nameColumnWidthStr = "%s".format(nameColumnWidth);
639 			*argStr ~= format("%s%-"~nameColumnWidthStr~"s%s%s\n",
640 			                  indent, argName~" ", requiredStr~arg.desc, defaultValStr);
641 		}
642 		if(basicArgStr != "" && advancedArgStr != "")
643 		{
644 			basicArgStr = "\nBasic: \n"~basicArgStr;
645 			advancedArgStr = "\nAdvanced: \n"~advancedArgStr;
646 		}
647 		return ret~basicArgStr~advancedArgStr;
648 	}
649 
650 	string getDetailedUsage()
651 	{
652 		string ret;
653 		string indent = "  ";
654 		string basicArgStr;
655 		string advancedArgStr;
656 		
657 		ret ~= "Switches: (prefixes can be '/', '-' or '--')\n";
658 		foreach(Arg arg; args)
659 		{
660 			string* argStr = arg.isAdvanced? &advancedArgStr : &basicArgStr;
661 
662 			string argName = arg.isSwitchless? "" : "-"~arg.name;
663 			if(arg.altName != "")
664 				argName ~= ", -"~arg.altName;
665 			if(!arg.isSwitchless || arg.altName != "")
666 				argName ~= " ";
667 	
668 			// For some reason, unbox can't see Arg's private member "defaultValue"
669 			auto argDefaultValue = arg.defaultValue;
670 			mixin(unbox!(argDefaultValue, "val"));
671 
672 			string defaultVal;
673 			string requiredStr;
674 			string toLowerStr;
675 			string switchlessStr;
676 			string advancedStr;
677 
678 			if(valAsInt)
679 				defaultVal = "%s".format(valAsInt());
680 			else if(valAsInts)
681 				defaultVal = "%s".format(valAsInts());
682 			else if(valAsBool)
683 				defaultVal = "%s".format(valAsBool());
684 			else if(valAsBools)
685 				defaultVal = "%s".format(valAsBools());
686 			else if(valAsStr)
687 				defaultVal = `"%s"`.format(valAsStr());
688 			else if(valAsStrs)  //TODO: Change this one from [ blah ] to [ "blah" ]
689 				defaultVal = "%s".format(valAsStrs());
690 
691 			defaultVal    = arg.isRequired   ? "" : ", Default: "~defaultVal;
692 			requiredStr   = arg.isRequired   ? "Required" : "Optional";
693 			toLowerStr    = arg.toLower      ? ", Case-Insensitive" : "";
694 			switchlessStr = arg.isSwitchless ? ", Nameless" : "";
695 			advancedStr   = arg.isAdvanced   ? ", Advanced" : ", Basic";
696 			
697 			*argStr ~= "\n";
698 			*argStr ~= format("%s(%s), %s%s%s%s%s\n",
699 			                  argName, getArgTypeName(arg),
700 			                  requiredStr, switchlessStr, toLowerStr, advancedStr, defaultVal);
701 			*argStr ~= format("%s\n", arg.desc);
702 		}
703 		ret ~= basicArgStr;
704 		ret ~= advancedArgStr;
705 		ret ~= "\n";
706 		ret ~= switchTypesMsg;
707 		return ret;
708 	}
709 }