1 /**
2  * Library that easily serializes and deserializes config files
3  * or directly provide them through the command line based on a
4  * given annotated struct.
5  *
6  * Examples:
7  * Basic use-case
8  * ---
9  * struct MyConfig
10  * {
11  *     @Desc("My number.")
12  *     int number;
13  *     @Desc("My bool.")
14  *     bool toggle;
15  * }
16  *
17  * enum usage = "My program version 1.0 does things.";
18  *
19  * int main(string[] args)
20  * {
21  *     string[] configArgs = getConfigArguments!Config("myconf.conf", args);
22  *
23  *     if (configArgs.length > 0)
24  *     {
25  *         import std.array : insertInPlace;
26  *
27  *         // Prepend them into the command line args
28  *         args.insertInPlace(1, configArgs);
29  *     }
30  *
31  *     MyConfig conf;
32  *     bool helpWanted = false;
33  *
34  *     import std.getopt : GetOptException;
35  *     try
36  *     {
37  *         conf = initializeConfig!(MyConfig, usage)(args, helpWanted);
38  *     }
39  *     catch (GetOptException e)
40  *     {
41  *         import std.stdio : stderr;
42  *         stderr.writefln("Invalid argument: %s", e.msg);
43  *         return 1;
44  *     }
45  *
46  *     if (helpWanted)
47  *     {
48  *         return 0;
49  *     }
50  * }
51  * ---
52  */
53 module zconfig;
54 
55 import std.getopt;
56 
57 /**
58  * Describes a section. When a struct member is annotated
59  * with it then the member will be written underneath `[section]`.
60  *
61  * During parsing the option will also only be valid if it is
62  * located underneath said `[section]`. It can also be used
63  * as a block annotation. See the examples.
64  *
65  * Examples:
66  * ---
67  * struct MyConfig
68  * {
69  *     @Section("foo") @Desc("My number of foo.")
70  *     int number;
71  * }
72  * ---
73  * Will serialize to:
74  * ```ini
75  * [foo]
76  * ; My number of foo.
77  * ; Default value: 0
78  * ;number=0
79  * ```
80  *
81  * Examples:
82  * ---
83  * struct MyConfig
84  * {
85  *     @Desc("My \"global\" option.")
86  *     int gNumber = -1;
87  *
88  *     @Section("bar")
89  *     {
90  *         @Desc("If true the toggle is activated.")
91  *         bool toggleMe;
92  *         @Desc("Another number.")
93  *         int increment = 1;
94  *     }
95  * }
96  * ---
97  * Will serialize to:
98  * ```ini
99  * ; My "global" option.
100  * ; Default value: -1
101  * ;gNumber=-1
102  *
103  * [bar]
104  * ; If true the toggle is activated.
105  * ; Default value: false
106  * ;toggleMe=false
107  *
108  * ; Another number.
109  * ; Default value: 1
110  * ;increment=1
111  * ```
112  */
113 struct Section
114 {
115     string section;
116 }
117 
118 /**
119  * Adds a description to the option which will be shown
120  * in the command line when `-h` is provided and in the
121  * config file.
122  *
123  * Examples:
124  * ---
125  * MyConfig
126  * {
127  *     @Desc("My config option.")
128  *     int option;
129  * }
130  * ---
131  * Will be serialized as:
132  * ```ini
133  * ; My config option.
134  * ; Default value: 0
135  * ;option=0
136  * ```
137  */
138 struct Desc
139 {
140     string description;
141 }
142 
143 /**
144  * Provides an alternative option name. This is mostly
145  * convenient on the command line.
146  *
147  * The shortname will be added verbatim. This allows
148  * to provide multiple alternatives at once.
149  * Internally the struct member name and the short name
150  * will be simply concatenated with the vertical bar ('|').
151  *
152  * Examples:
153  * ---
154  * struct MyConfig
155  * {
156  *      @Short("v") @Desc("Print verbose messages.")
157  *      bool verbose;
158  * }
159  * ---
160  * Allows the usage of `./myapp --verbose` and
161  * `./myapp -v`. Both will set the verbose member variable.
162  */
163 struct Short
164 {
165     string shortname;
166 }
167 
168 private struct Argument
169 {
170     string section;
171     string description;
172     string shortname;
173     bool onlyCLI = false;
174     bool configFile = false;
175     bool passThrough = false;
176     bool required = false;
177 }
178 
179 /// Do not serialize and deserialize this option from the config file.
180 /// The option will only be available through the command line arguments.
181 enum OnlyCLI;
182 /**
183  * Special option that allows pointing to another config file (ignoring the default).
184  * Can only be provided through the command line arguments. A config struct may only
185  * have one ConfigFile annotation. Otherwise an error is thrown.
186  *
187  * Examples:
188  * ---
189  * struct MyConfig
190  * {
191  *     @ConfigFile @Short("c") @Desc("Specific config file to use instead of the default.")
192  *     string config = "myconf.conf";
193  * }
194  * ---
195  */
196 enum ConfigFile;
197 /// Getopt PassThrough.
198 enum PassThrough;
199 /// Getopt Required. If the option is not provided an exception will be thrown.
200 enum Required;
201 
202 /**
203  * Calls getopt with the provided args array. Usually you want to first call
204  * [getConfigArguments] and merge the command line arguments with the config arguments.
205  * Which then get passed to this function.
206  *
207  * The template `ConfigType` is the plain old data struct that
208  * describes the options that are used to generate the getopt parameter list.
209  * `usage` is used for the [defaultGetoptPrinter] for the usage description when `-h`
210  * is provided in the args.
211  *
212  * Params:
213  *   args = The arguments to provide to getopt
214  *   helpWanted = if `-h` has been parsed by getopt this out parameter will indicate that
215  *
216  * Returns:
217  *   A fully filled out `ConfigType` struct
218  *
219  * Examples:
220  * ---
221  * Only parse command line arguments
222  * struct MyConfig
223  * {
224  *     @Desc("My number.")
225  *     int number;
226  *     @Desc("My bool.")
227  *     bool toggle;
228  * }
229  *
230  * enum usage = "My program version 1.0 does things.";
231  *
232  * int main(string[] args)
233  * {
234  *     import std.getopt : GetOptException;
235  *
236  *     MyConfig conf;
237  *     bool helpWanted = false;
238  *
239  *     try
240  *     {
241  *         conf = initializeConfig!(MyConfig, usage)(args, helpWanted);
242  *     }
243  *     catch (GetOptException e)
244  *     {
245  *         import std.stdio : stderr;
246  *         stderr.writefln("Invalid argument: %s", e.msg);
247  *         return 1;
248  *     }
249  *
250  *     if (helpWanted)
251  *     {
252  *         return 0;
253  *     }
254  * }
255  * ---
256  */
257 ConfigType initializeConfig(ConfigType, string usage)(ref string[] args, out bool helpWanted)
258 {
259     ConfigType newConf;
260     arraySep = ",";
261     mixin(`auto helpInformation = getopt(`, args.stringof,
262             generateGetoptArgumentList!(ConfigType, newConf.stringof), `);`);
263     if (helpInformation.helpWanted)
264     {
265         defaultGetoptPrinter(usage, helpInformation.options);
266         helpWanted = true;
267     }
268     return newConf;
269 }
270 
271 private string generateGetoptArgumentList(ConfigType, string configStructName)()
272 {
273     if (__ctfe)
274     {
275         string arglist = "";
276         immutable ConfigType defaultConfig;
277         foreach (memberName; __traits(allMembers, ConfigType))
278         {
279             string attribute = memberName;
280             immutable argument = getConfigMemberUDAs!(ConfigType, memberName);
281             static if (argument.passThrough)
282             {
283                 arglist ~= `,std.getopt.config.passThrough`;
284             }
285             static if (argument.required && !argument.passThrough)
286             {
287                 arglist ~= `,std.getopt.config.required`;
288             }
289             static if (argument.shortname)
290             {
291                 attribute ~= `|` ~ argument.shortname;
292             }
293             arglist ~= `,"` ~ attribute ~ `"`;
294             static if (argument.description)
295             {
296                 import std.conv : to;
297                 import std.format : format;
298                 import std.traits : isArray, isSomeString;
299 
300                 auto member = __traits(getMember, defaultConfig, memberName);
301                 static if (isArray!(typeof(member)) && !isSomeString!(typeof(member)))
302                 {
303                     string defaultValue = format("%-(%s,%)", member);
304                 }
305                 else
306                 {
307                     string defaultValue = member.to!string;
308                 }
309 
310                 arglist ~= `,"` ~ argument.description ~ ` Default: ` ~
311                     defaultValue ~ `"`;
312             }
313             arglist ~= ",&" ~ configStructName ~ "." ~ memberName;
314         }
315         return arglist;
316     }
317     else
318     {
319         return "";
320     }
321 }
322 
323 private Argument getConfigMemberUDAs(ConfigType, string memberName)()
324 {
325     Argument arg;
326     foreach (attr; __traits(getAttributes, __traits(getMember, ConfigType, memberName)))
327     {
328         static if (is(typeof(attr) == Section))
329         {
330             arg.section = attr.section;
331         }
332         else static if (is(typeof(attr) == Short))
333         {
334             arg.shortname = attr.shortname;
335         }
336         else static if (is(typeof(attr) == Desc))
337         {
338             arg.description = attr.description;
339         }
340         else static if (is(attr == OnlyCLI))
341         {
342             arg.onlyCLI = true;
343         }
344         else static if (is(attr == ConfigFile))
345         {
346             arg.configFile = true;
347         }
348         else static if (is(attr == PassThrough))
349         {
350             arg.passThrough = true;
351         }
352         else static if (is(attr == Required))
353         {
354             arg.required = true;
355         }
356     }
357     return arg;
358 }
359 
360 /***
361  * Creates an argument array that contains the options provided in
362  * the config file.
363  *
364  * The resulting array contains all provided and valid
365  * options excluding ones that were provided through the
366  * command line.
367  * The template `ConfigType` is the plain old data struct that
368  * describes the options that are read from the config filename.
369  *
370  * Params:
371  *   filename = Filename of the config file
372  *   args = Command line arguments provided through main(string[] args)
373  *
374  * Returns: Config exclusive argument array
375  *
376  * Examples:
377  * ---------
378  * struct MyConfig
379  * {
380  *     @Desc("My cool number.")
381  *     int number;
382  *     @Desc("Print verbose messages.")
383  *     bool verbose = false;
384  * }
385  *
386  * int main(string[] args)
387  * {
388  *     string[] configArgs = getConfigArguments!MyConfig("myconfig.conf", args);
389  *
390  *     import std.stdio : writeln;
391  *     writeln(configArgs);
392  * }
393  * ---------
394  * Assume we have a `myconfig.conf` file that contains the following options:
395  * ```ini
396  * ; My cool number.
397  * number=5
398  * ; Print verbose messages.
399  * verbose=true
400  * ```
401  * Calling the above program without any arguments: `./myapp`
402  * will print: `["--number", "5", "--verbose", "true"]`.
403  *
404  * Calling the program with a given argument: `./myapp --number=12`
405  * will print: `["--verbose", "true"]`.
406  * You can see that our provided `--number=12` has been excluded.
407  */
408 string[] getConfigArguments(ConfigType)(string filename, string[] args)
409 {
410     import std.algorithm : splitter, each, findSplit;
411     import std.stdio : File, writeln;
412     import std.array : empty, split;
413 
414     int[string] identifierMap;
415     string[string] shortnameLookupMap;
416     bool haveCustomConfigFile = false;
417     string configFileMember;
418     foreach (memberName; __traits(allMembers, ConfigType))
419     {
420         immutable argument = getConfigMemberUDAs!(ConfigType, memberName);
421         static if (!argument.onlyCLI && !argument.configFile)
422         {
423             identifierMap[memberName] = 1;
424         }
425         static if (argument.configFile)
426         {
427             assert(haveCustomConfigFile == false, "Can only have one config member with the 'ConfigFile' attribute.");
428             haveCustomConfigFile = true;
429             configFileMember = memberName;
430         }
431         argument.shortname.splitter('|').each!(name => shortnameLookupMap[name] = memberName);
432     }
433 
434     int[string] argMap;
435 
436     bool argIsConfig = false;
437     string configFilename;
438 
439     // Create mappings of each option and extract the special 'ConfigFile'
440     // value if it was provided. The mappings are used to compare against
441     // the values provided in the configuration file.
442     foreach (arg; args)
443     {
444         if (argIsConfig)
445         {
446             configFilename = arg;
447             continue;
448         }
449         import std.string : indexOf;
450 
451         auto optionIdentIndex = arg.indexOf(assignChar);
452         string optionIdent;
453         string optionName;
454 
455         if (optionIdentIndex < 0)
456         {
457             optionIdentIndex = arg.length;
458         }
459 
460         if (arg.length > 2 && arg[0] == optionChar && arg[1] == optionChar)
461         {
462             optionIdent = arg[2 .. optionIdentIndex];
463             if (optionIdent in shortnameLookupMap)
464             {
465                 optionName = shortnameLookupMap[optionIdent];
466                 argMap[optionName] = 1;
467             }
468             else
469             {
470                 optionName = optionIdent;
471                 argMap[optionIdent] = 1;
472             }
473         }
474         else if (arg.length > 1 && arg[0] == optionChar)
475         {
476             optionIdent = arg[1 .. optionIdentIndex];
477             if (optionIdent in shortnameLookupMap)
478             {
479                 optionName = shortnameLookupMap[optionIdent];
480                 argMap[optionName] = 1;
481             }
482             else
483             {
484                 optionName = optionIdent;
485                 argMap[optionIdent] = 1;
486             }
487         }
488         if (optionName == configFileMember)
489         {
490             if (optionIdentIndex < arg.length)
491             {
492                 configFilename = arg[optionIdentIndex + 1 .. $];
493             }
494             else
495             {
496                 argIsConfig = true;
497             }
498         }
499     }
500 
501     if (configFilename == configFilename.init)
502     {
503         configFilename = filename;
504     }
505 
506     import std.exception : ErrnoException;
507     import std.stdio : stderr;
508 
509     string[string] confMap;
510     File inFile;
511 
512     try
513     {
514         inFile = File(configFilename, "r");
515     }
516     catch (ErrnoException e)
517     {
518         stderr.writefln("Error opening config file: %s", e.msg);
519         return [];
520     }
521 
522     scope (exit)
523         inFile.close();
524 
525     foreach (line; inFile.byLine())
526     {
527         if (line.empty || line[0] == ';')
528         {
529             continue;
530         }
531         if (auto splitted = line.findSplit([assignChar]))
532         {
533             // Only keep the values if there wasn't a command line argument
534             // with the same identifier
535             if (cast(const string) splitted[0] in identifierMap &&
536                     !(cast(const string) splitted[0] in argMap))
537             {
538                 const key = splitted[0].dup;
539                 confMap[key] = splitted[2].dup;
540             }
541         }
542     }
543 
544     const confKeys = confMap.keys();
545     string[] additionalConfArgs;
546     additionalConfArgs.reserve(confKeys.length);
547     foreach (name; confKeys)
548     {
549         import std.utf : toUTF8;
550 
551         additionalConfArgs ~= [[optionChar].toUTF8 ~ [optionChar].toUTF8 ~ name, confMap[name]];
552     }
553 
554     return additionalConfArgs;
555 }
556 
557 /**
558  * Writes an example config file to the provided filename.
559  * If a struct member does not provide a default value then
560  * .init is used as the default value.
561  *
562  * The only exception being arrays which would normally
563  * default to '[]' are instead simply left blank. If an array
564  * provides a default list then they are printed comma separated.
565  * The reason is that getopt expects such format instead of the
566  * brackets.
567 
568  * Params:
569  *   filename = Filename to write the example config file to
570  */
571 void writeExampleConfigFile(ConfigType)(const string filename)
572 {
573     import std.stdio : File;
574     import std.format : formattedWrite, format;
575     import std.string : wrap;
576     import std.array : appender;
577     import std.conv : to;
578     import std.traits : isArray, isSomeString;
579 
580     string currentSection = "";
581     immutable ConfigType defaultConfig;
582     auto outFile = File(filename, "w+");
583     scope (exit)
584         outFile.close();
585     auto app = appender!string;
586 
587     foreach (memberName; __traits(allMembers, ConfigType))
588     {
589         immutable argument = getConfigMemberUDAs!(ConfigType, memberName);
590         if (argument.onlyCLI == true || argument.configFile)
591         {
592             continue;
593         }
594         if (currentSection != argument.section)
595         {
596             currentSection = argument.section;
597             app.formattedWrite("[%s]\n", currentSection);
598         }
599         static if (argument.description)
600         {
601             app.formattedWrite("%s", wrap(argument.description, 80, "; ", "; "));
602         }
603 
604         auto member = __traits(getMember, defaultConfig, memberName);
605         static if (isArray!(typeof(member)) && !isSomeString!(typeof(member)))
606         {
607             string defaultValue = format("%-(%s,%)", member);
608         }
609         else
610         {
611             string defaultValue = member.to!string;
612         }
613         app.formattedWrite("; Default value: %s\n", defaultValue);
614         app.formattedWrite(";%s=%s\n", memberName, defaultValue);
615         app ~= "\n";
616     }
617     outFile.write(app.data);
618 }