1 /**
2  * Command line argument handling based on UDAs.
3  */
4 module iz.options;
5 
6 import
7     std.traits, std.meta, std.typecons, std.conv, std.stdio;
8 import
9     iz.enumset;
10 
11 /// Possible flags associated to an argument.
12 enum ArgFlag: ubyte
13 {
14     /// Argument must have a matching command line element.
15     mandatory,
16     /// Processing stops successfully if the matching command line element is found.
17     stopper,
18     // On error either don't throw or don't exit `handleArguments` with `false`.
19     //nonFatal,
20     // Collect args from first element until first hyphenized arg. Target must be `string[]`
21     //files,
22 }
23 
24 /// AFmandatory, AFstopper, etc
25 mixin AliasedEnumMembers!(ArgFlag, "AF");
26 
27 //TODO: handle argument flags.
28 //TODO: handle help.
29 //TODO: workaround the fact that unittest dont work with static struct nested in unittest{}
30 //TODO: handle quoted values in argNameAndValue().
31 //TODO: add a system to filter files automatically
32 //TODO: either handle or specifies as not handled, the superfluous arg passes (e.g typo)
33 
34 /// Set of $(D ArgFlag).
35 alias ArgFlags = EnumSet!(ArgFlag, Set8);
36 
37 /**
38  * Used to describe a program argument.
39  *
40  * Handled types are $(D bool), everything that impliclty converts to
41  * $(D ulong), $(D real), $(D string) or $(D dchar), $(D void function()) and
42  * finally the function setters for the types listed above.
43  */
44 struct Argument
45 {
46     /// The expected string, including the hyphens
47     immutable string name;
48     /// Help string associated to the argument.
49     immutable string help;
50     /// The flags.
51     immutable ArgFlags flags;
52     /// Helpers for the flags.
53     bool isMandatory() const {return ArgFlag.mandatory in flags;}
54     /// ditto
55     bool isStopper() const {return ArgFlag.stopper in flags;}
56     // ditto
57     //bool isFatal() const {return ArgFlag.nonFatal !in flags;}
58 }
59 
60 /**
61  * Collects the description of the arguments in a tuple of $(D Argument).
62  * Can be used to build custom help messages.
63  *
64  * Parameters:
65  *      Locations = The modules, $(D struc)s or $(D class)es containing the
66  *          $(D @Argument) variables. When declared in an aggregate,
67  *          the variables must be static.
68  */
69 template ArgDescriptions(Locations...)
70 {
71     static if (Locations.length == 0)
72     {
73         alias ArgDescriptions = AliasSeq!();
74     }
75     else static if (Locations.length == 1)
76     {
77         alias getArgument(alias S) = getUDAs!(S, Argument);
78         alias Symbols = getSymbolsByUDA!(Locations, Argument);
79         alias ArgDescriptions = staticMap!(getArgument,  Symbols);
80         enum check(alias T) = T.name != ArgDescriptions[0].name;
81         static assert(allSatisfy!(check, ArgDescriptions[1..$]),
82             "duplicated Argument description: " ~ ArgDescriptions[0].name);
83     }
84     else static if (Locations.length > 1)
85     {
86         alias ArgDescriptions = AliasSeq!(
87             ArgDescriptions!(Locations[0..$/2]),
88             ArgDescriptions!(Locations[$/2..$])
89         );
90         enum check(alias T) = T.name != ArgDescriptions[0].name;
91         static assert(allSatisfy!(check, ArgDescriptions[1..$]),
92             "duplicated Argument description: " ~ ArgDescriptions[0].name);
93     }
94 }
95 
96 version(unittest)
97 {
98     static struct ContainerWithDups
99     {
100         static @Argument("-a") bool a;
101         static @Argument("-a") bool ab;
102     }
103     static struct Cwd1
104     {
105         static @Argument("-a") bool a;
106     }
107     static struct Cwd2
108     {
109         static @Argument("-a") bool b;
110     }
111 }
112 
113 unittest
114 {
115     static assert(!__traits(compiles, ArgDescriptions!ContainerWithDups));
116     static assert(!__traits(compiles, ArgDescriptions!(Cwd1,Cwd2)));
117 }
118 
119 // Collects the informations allowing to set the arg targets using their ident.
120 private template ArgumentTargets(Locations...)
121 {
122     static if (Locations.length == 0)
123     {
124         alias ArgumentTargets = AliasSeq!();
125     }
126     else static if (Locations.length == 1)
127     {
128         template getTarget(alias S)
129         {
130             enum tmp = __traits(identifier, S);
131             static if (tmp[0] == '(')
132                 enum sym = tmp[1..$-1];
133             else
134                 enum sym = tmp;
135 
136             enum loc = Locations.stringof[0] == '(' ? Locations.stringof[1..$-1] : Locations.stringof;
137             enum fqn = fullyQualifiedName!S;
138             enum mod = fqn[0 .. $ - (loc.length + sym.length + 2)];
139             enum getTarget = tuple(typeof(S).stringof, fqn, mod);
140         }
141         alias Symbols = getSymbolsByUDA!(Locations, Argument);
142         alias ArgumentTargets = staticMap!(getTarget,  Symbols);
143     }
144     else static if (Locations.length > 1)
145     {
146         alias ArgumentTargets = AliasSeq!(
147             ArgumentTargets!(Locations[0..$/2]),
148             ArgumentTargets!(Locations[$/2..$])
149         );
150     }
151 }
152 
153 unittest
154 {
155     static struct Container1
156     {
157         static @Argument("-a", "help for a", ArgFlags(AFmandatory)) bool a;
158         static @Argument("--ab", "help for ab", ArgFlags(AFmandatory, AFstopper)) bool ab;
159     }
160     static struct Container2
161     {
162         static @Argument("-c", "help for c") bool c;
163         static @Argument("--cd", "help for cd") bool cd;
164     }
165 
166     alias Descr = ArgDescriptions!(Container1, Container2);
167     static assert(Descr.length == 4);
168     assert(Descr[0].isMandatory /*&& Descr[0].isFatal*/);
169     assert(Descr[1].isMandatory && Descr[1].isStopper);
170 }
171 
172 /**
173  * Splits the argument and its optional value. This function is public in order
174  * to document the possible syntaxes of an argument.
175  *
176  * Grammar:
177  *        Identifier
178  *      | '-' Character IdentifierOrValue
179  *      | '-' Character ('=' IdentifierOrValue)?
180  *      | '--' Identifier ('=' IdentifierOrValue)?
181  *      ;
182  *
183  * Returns:
184  *      An array of two strings. The first is always set, it represents the
185  *      argument name, including the hyphens. The second, optional is its
186  *      associated value.
187  */
188 string[2] argNameAndValue(S = string)(const auto ref S s)
189 in
190 {
191     assert(s.length);
192     if (s[0] == '-')
193         assert(s.length >= 2, "minimal length of an hyphenized argument is 2 (-<letter>)");
194 }
195 do
196 {
197     import std..string : indexOf;
198     string[2] result;
199 
200     // simple command
201     if (s[0] != '-')
202     {
203         result[0] = s;
204     }
205     else
206     {
207         // arg cant begin with '='
208         if (const ptrdiff_t p = s.indexOf('=') + 1)
209         {
210             result[0] = s[0..p-1];
211             result[1] = s[p..$];
212         }
213         else
214         {
215             // no equal, single "-"
216             if (s.length > 2 && s[0..2] != "--")
217             {
218                 result[0] = s[0..2];
219                 result[1] = s[2..$];
220             }
221             else
222             {
223                 result[0] = s[0..$];
224             }
225         }
226     }
227     return result;
228 }
229 /// Accepted syntax
230 pure nothrow @safe @nogc unittest
231 {
232     assert("-a".argNameAndValue == ["-a", ""]);
233     assert("cmd".argNameAndValue == ["cmd", ""]);
234     assert("-tTRUE".argNameAndValue == ["-t", "TRUE"]);
235     assert("-a=true".argNameAndValue == ["-a", "true"]);
236     assert("--a=42".argNameAndValue == ["--a", "42"]);
237     assert("command".argNameAndValue == ["command", ""]);
238 }
239 /// Invalid syntax
240 @system unittest
241 {
242     import std.exception: assertThrown;
243     import core.exception: AssertError;
244     assertThrown!AssertError("-".argNameAndValue);
245     assertThrown!AssertError("".argNameAndValue);
246     //assertThrown!AssertError("--".argNameAndValue);
247 }
248 
249 /**
250  * Handles program arguments.
251  *
252  * This is a new generation (as for Spring 2018...), faster but simpler,
253  * $(D getopt)-like function, based on UDA and that contains only the code needed
254  * to handle the possible arguments, since it is heavily based on the D template
255  * meta-programming techniques.
256  * The possible arguments are defined at compile time.
257  *
258  * Params:
259  *      mustThrow = Indicates wether exceptions are gagged (or if the code to
260  *          throw them is generated). When set to $(D Throw.No),
261  *          the function is callable by a $(D @nothrow) caller.
262  *          Success is always indicated in the result.
263  *      Locations = Indicates the modules, $(D struct)s or $(D class)es containing the
264  *          $(D @Argument) variables. When declared in an aggregate, the variables must
265  *          be static.
266  *      args = The arguments passed to $(D main(string[] args)), excluding the program name.
267  *
268  * Returns:
269  *      $(D true) if $(D Locations) requirements were encountered, $(D false) otherwise.
270  *
271  * Throws:
272  *      Only if $(D mustThrow), a $(D ConvException) if one of the value passed
273  *      in an argument does not convert to the matching target type or
274  *      a simple $(D Exception) if one of the $(D Argument) flag is not verified
275  *      (such as $(D ArgFlag.mandatory)).
276  */
277 bool handleArguments(Flag!"Throw" mustThrow, Locations...)(string[] args)
278 {
279     static assert(Locations.length > 0,
280         "need at least 1 location containing @Argument variable(s)");
281     static assert((Locations.length == 1 && __VERSION__ < 2080L) ||
282                   (Locations.length >= 1 && __VERSION__ > 2079L),
283         "multiple locations are only handled from D version 2.080");
284 
285     bool             result = true;
286     alias            argTrgts = ArgumentTargets!Locations;
287     static immutable argDescr = ArgDescriptions!Locations;
288     string[2]        argParts;
289     bool[argTrgts.length] done;
290 
291     enum gotoL0withFalse = "{result = false; goto L0;}";
292 
293     static foreach(i, t; argTrgts)
294     {
295         foreach(ref arg; args)
296         {
297             mixin("import " ~ t[2] ~ ";");
298             argParts = argNameAndValue(arg);
299             if (argParts[0] == argDescr[i].name)
300             {
301                 // functions
302                 alias FunT = typeof(mixin(t[1]));
303                 static if (isFunction!FunT)
304                 {
305                     alias P = Parameters!FunT;
306                     static assert(P.length <= 1, "invalid number of parameters for " ~ t[1]);
307 
308                     // bool
309                     static if (P.length == 0)
310                     {
311                         if (argParts[1].length == 0)
312                         {
313                             done[i] = true;
314                             mixin(t[1] ~ "();");
315                             static if (argDescr[i].isStopper)
316                                 goto L0;
317                         }
318                         else static if (argDescr[i].isMandatory)
319                             mixin(gotoL0withFalse);
320                     }
321                     else static if (is(Unqual!(P[0]) == bool))
322                     {
323                         if (argParts[1] == "false")
324                         {
325                             mixin(t[1] ~ "(false);");
326                             done[i] = true;
327                             static if (argDescr[i].isStopper)
328                                 goto L0;
329                         }
330                         else if (argParts[1] == "true")
331                         {
332                             mixin(t[1] ~ "(true);");
333                             done[i] = true;
334                             static if (argDescr[i].isStopper)
335                                 goto L0;
336                         }
337                         else static if (argDescr[i].isMandatory)
338                             mixin(gotoL0withFalse);
339                     }
340                     // implictly convertible types
341                     else static foreach(IC; AliasSeq!(ulong, real, string, dchar))
342                     {{
343                         alias TT = Unqual!(P[0]);
344                         static if (is(TT : IC))
345                         {
346                             if (argParts[1].length != 0)
347                             {
348                                 static if (mustThrow)
349                                 {
350                                     const TT value = to!TT(argParts[1]);
351                                 }
352                                 else
353                                 {
354                                     TT value; // = void  + @trusted
355                                     try value = to!TT(argParts[1]);
356                                     catch (Exception)
357                                         mixin(gotoL0withFalse);
358                                 }
359                                 done[i] = true;
360                                 mixin(t[1] ~ "(value);");
361                                 static if (argDescr[i].isStopper)
362                                     goto L0;
363                             }
364                             else static if (argDescr[i].isMandatory)
365                                 mixin(gotoL0withFalse);
366                         }
367                     }}
368                 }
369                 else
370                 {
371                     mixin("alias TT = " ~ t[0] ~ ";");
372                     // bool
373                     static if (is(TT == bool))
374                     {
375                         if (argParts[1].length == 0 || argParts[1] == "true")
376                         {
377                             done[i] = true;
378                             mixin(t[1]) = true;
379                             static if (argDescr[i].isStopper)
380                                 goto L0;
381                         }
382                         else if (argParts[1] == "false")
383                         {
384                             done[i] = true;
385                             mixin(t[1]) = false;
386                             static if (argDescr[i].isStopper)
387                                 goto L0;
388                         }
389                         else static if (argDescr[i].isMandatory)
390                         {
391                             result = false;
392                             goto L0;
393                         }
394                     }
395                     // implictly convertible types
396                     else static foreach(IC; AliasSeq!(ulong, real, string, dchar))
397                     {
398                         static if (is(TT : IC))
399                         {
400                             if (argParts[1].length != 0)
401                             {
402                                 static if (mustThrow)
403                                 {
404                                     const TT value = to!TT(argParts[1]);
405                                 }
406                                 else
407                                 {
408                                     TT value; // = void  + @trusted
409                                     try value = to!TT(argParts[1]);
410                                     catch (Exception)
411                                         mixin(gotoL0withFalse);
412                                 }
413                                 done[i] = true;
414                                 mixin(t[1]) = value;
415                                 static if (argDescr[i].isStopper)
416                                     goto L0;
417                             }
418                             else static if (argDescr[i].isMandatory)
419                                 mixin(gotoL0withFalse);
420                         }
421                     }
422                 }
423             }
424         }
425         static if (argDescr[i].isMandatory)
426         {
427             if (!done[i])
428             {
429                 result = false;
430                 static if (mustThrow)
431                     throw new Exception("mandatory argument not set: " ~
432                         argDescr[i].name);
433                 else goto L0;
434             }
435         }
436     }
437     L0:
438 
439     static if (mustThrow)
440     {
441         if (!result)
442             throw new Exception("Invalid arguments: " ~ to!string(args));
443     }
444 
445     return result;
446 }
447 ///
448 version(D_Ddoc) @safe unittest
449 {
450     // the definition container does not have to be dedicated to the sole purpose
451     // of hosting the options and several containers are allowed.
452     struct ArgumentDefinition
453     {
454         // simple bool argument.
455         static @Argument("-verbose", "as much blabla as possible...") bool v;
456         // an int, requiring to use "=".
457         static @Argument("--numThreads", "compute in parallel") int n;
458         // a stopper flag: it shortcuts arguments processing if found.
459         static @Argument("--update", "", ArgFlags(ArgFlag.stopper)) bool u;
460         // bool function, works without pointer.
461         static @Argument("--help") void help() @safe {writeln("RTFM");}
462     }
463     // a @safe argument checking...
464     assert(handleArguments!(No.Throw, ArgumentDefinition)(["-verbose", "--numThreads=8"]));
465     // error reporting is then a simple bool,
466     // which is however enough to display the help.
467     if (!handleArguments!(No.Throw, ArgumentDefinition)(["-verbose", "--numThreads=whoops"]))
468         writeln(help!ArgumentDefinition);
469 }
470 /**
471  * Returns: The default help string for the $(D Argument) declared in the $(D Locations).
472  */
473 string help(Locations...)()
474 {
475     import std.format: format;
476 
477     string result;
478     size_t width;
479     static foreach(a; ArgDescriptions!Locations)
480     {
481         if (a.name.length > width)
482             width = a.name.length;
483     }
484     string specifier = "%-" ~ to!string(width) ~ "s : %s\n";
485     static foreach(a; ArgDescriptions!Locations)
486     {
487         result ~= specifier.format(a.name, a.help);
488     }
489     return result;
490 }
491 
492 version(unittest) private static struct Container
493 {
494     static @Argument("-a", "help for a") bool a;
495     static @Argument("--seed", "help for seed") int seed;
496     static @Argument("--letter", "help for letter") char letter;
497     static @Argument("--fname", "help for fname") string fname;
498     static @Argument("--scaleX", "help for scaleX") double scaleX;
499 
500     static bool c;
501     static @Argument("-c", "help for c") void cFun() @safe nothrow {c = true;}
502 }
503 
504 unittest
505 {
506     string a = help!Container;
507 }
508 
509 @safe nothrow unittest
510 {
511     assert(handleArguments!(No.Throw, Container)(
512         ["-a", "-c", "--seed=42", "--fname=/home/fantomas.txt", "--scaleX=1.0", "--letter=K"]
513     ));
514     assert(Container.a);
515     assert(Container.c);
516     assert(Container.fname == "/home/fantomas.txt");
517     assert(Container.scaleX == 1.0);
518     assert(Container.seed == 42);
519     assert(Container.letter == 'K');
520     assert(handleArguments!(No.Throw, Container)(["-a=false"]));
521     assert(!Container.a);
522     assert(handleArguments!(No.Throw, Container)(["-a=true"]));
523     assert(Container.a);
524 }
525 
526 @safe nothrow unittest
527 {
528     assert(!handleArguments!(No.Throw, Container)(
529         ["-a", "-c", "--seed=reallyNAN", "--fname=/home/fantomas.txt", "--scaleX=1.0"]
530     ));
531 }
532 
533 @safe nothrow unittest
534 {
535     assert(handleArguments!(No.Throw, Container)(["-c"]));
536 }
537 
538 @safe unittest
539 {
540     assert(handleArguments!(Yes.Throw, Container)(["-c"]));
541 }
542 
543 @safe unittest
544 {
545     import std.exception : assertThrown;
546     assertThrown(handleArguments!(Yes.Throw, Container)(["--seed=reallyNAN"]));
547 }
548 
549 version(unittest) private static struct ContainerWithMandatories
550 {
551     static @Argument("--s0", "", ArgFlags(ArgFlag.mandatory) ) int s0;
552     static @Argument("--s1", "", ArgFlags(ArgFlag.mandatory) ) int s1;
553 }
554 
555 @safe unittest
556 {
557     import std.exception : assertThrown;
558     assertThrown(handleArguments!(Yes.Throw, ContainerWithMandatories)(["--s0=1"]));
559     assertThrown(handleArguments!(Yes.Throw, ContainerWithMandatories)(["--s1=1"]));
560     assert(handleArguments!(Yes.Throw, ContainerWithMandatories)(["--s1=1", "--s0=2"]));
561 }
562 
563 @safe nothrow unittest
564 {
565     assert(!handleArguments!(No.Throw, ContainerWithMandatories)(["--s0=1"]));
566     assert(!handleArguments!(No.Throw, ContainerWithMandatories)(["--s1=1"]));
567     assert(handleArguments!(No.Throw, ContainerWithMandatories)(["--s1=1", "--s0=2"]));
568 }
569 
570 @safe nothrow unittest
571 {
572     assert(!handleArguments!(No.Throw, ContainerWithMandatories)(["--s1=1", "--s0"]));
573     assert(!handleArguments!(No.Throw, ContainerWithMandatories)(["--s1=1", "--s0="]));
574     assert(!handleArguments!(No.Throw, ContainerWithMandatories)(["--s1=1", "--s0=$$"]));
575 }
576 
577 version(unittest) private static struct ContainerWithStop
578 {
579     static @Argument("--s0", "", ArgFlags(ArgFlag.mandatory, ArgFlag.stopper) ) int s0;
580     static @Argument("--s1", "", ArgFlags(ArgFlag.mandatory) ) int s1;
581 }
582 
583 @safe nothrow unittest
584 {
585     // dont care about s1 being mandatory.
586     assert(handleArguments!(No.Throw, ContainerWithStop)(["--s0=1"]));
587     assert(!handleArguments!(No.Throw, ContainerWithStop)(["--s1=1"]));
588     // stopped before s1 error
589     assert(handleArguments!(No.Throw, ContainerWithStop)(["--s0=1", "--s1"]));
590 }
591 
592 version(unittest) private static struct ContainerWithFuncs
593 {
594     static int s0;
595     static float s1 = 123;
596     static @Argument("--s0") void s0Fun(const int v) @safe {s0 = v;}
597     static @Argument("--s1") void s1Fun(float v) @safe {s1 = v;}
598 }
599 
600 @safe unittest
601 {
602     assert(handleArguments!(No.Throw, ContainerWithFuncs)(["--s0=1", "--s1=1.0"]));
603     assert(ContainerWithFuncs.s0 == 1);
604     static if (__VERSION__ >= 2080L)
605         assert(ContainerWithFuncs.s1 == 1.0f, to!string(ContainerWithFuncs.s1));
606 }
607 
608 
609 version(none)
610 {
611 
612     struct ContainerForBenchmark
613     {
614         static @Argument("--num") int num;
615         static @Argument("-v") bool v;
616         static @Argument("-f") bool f;
617         static @Argument("-t") bool t;
618     }
619 
620     import std.datetime.stopwatch;
621     static string[] args = ["program", "--num=8", "-v", "-f", "-t"];
622 
623     static void stdO() @safe
624     {
625         import std.getopt: getopt;
626         getopt(args,    "num", &ContainerForBenchmark.num,
627                         "v", &ContainerForBenchmark.v,
628                         "f", &ContainerForBenchmark.f,
629                         "t", &ContainerForBenchmark.t);
630     }
631 
632     static void izO() @safe nothrow
633     {
634         handleArguments!(No.Throw, ContainerForBenchmark)(args[1..$]);
635     }
636 
637     void main()
638     {
639 
640         benchmark!(stdO)(1_000_000)[0].writeln(" (std)");
641         benchmark!(izO)(1_000_000)[0].writeln(" (IZ)");
642         benchmark!(stdO)(1000)[0].writeln(" (std)");
643         benchmark!(izO)(1000)[0].writeln(" (IZ)");
644         benchmark!(stdO)(10)[0].writeln(" (std)");
645         benchmark!(izO)(10)[0].writeln(" (IZ)");
646         benchmark!(stdO)(1)[0].writeln(" (std)");
647         benchmark!(izO)(1)[0].writeln(" (IZ)");
648     }
649 }