1 /**
2     Validate and parse config files.
3 
4     Copyright: © 2019 Arne Ludwig <arne.ludwig@posteo.de>
5     License: Subject to the terms of the MIT license, as written in the
6              included LICENSE file.
7     Authors: Arne Ludwig <arne.ludwig@posteo.de>
8 */
9 module darg_plus.configfile;
10 
11 import darg :
12     Argument,
13     isArgumentHandler,
14     isOptionHandler,
15     Option,
16     OptionFlag;
17 import vibe.data.json : Json;
18 
19 ///
20 unittest
21 {
22     import darg : Multiplicity;
23     import vibe.data.json;
24 
25     auto config = serializeToJson([
26         "file": Json("/path/to/file"),
27         "more_files": serializeToJson([
28             "/path/to/file1",
29             "/path/to/file2",
30         ]),
31         "num": Json(42),
32         "verbose": Json(true),
33     ]);
34 
35     struct Options
36     {
37         @Argument("<in:file>")
38         string file;
39 
40         @Argument("<in:more_files>", Multiplicity.zeroOrMore)
41         string[] moreFiles;
42 
43         @Option("num")
44         size_t num;
45 
46         @Option("verbose")
47         OptionFlag verbose;
48     }
49 
50     auto options = parseConfig!Options(config);
51 
52     assert(options.file == "/path/to/file");
53     assert(options.moreFiles == [
54         "/path/to/file1",
55         "/path/to/file2",
56     ]);
57     assert(options.num == 42);
58     assert(options.verbose == true);
59 }
60 
61 ///
62 unittest
63 {
64     import vibe.data.json;
65 
66     auto config = serializeToJson([
67         "file": Json("/path/to/file"),
68         "num": Json(42),
69     ]);
70 
71     struct Options
72     {
73         @Argument("<in:file>")
74         string file;
75 
76         @Option("num")
77         size_t num = 1337;
78     }
79 
80     Options options;
81 
82     // Config values override default option values and arguments that are
83     // marked as empty:
84     options.file = configEmptyArgument;
85     assert(options.num == 1337);
86 
87     options = retroInitFromConfig(options, config);
88 
89     assert(options.file == "/path/to/file");
90     assert(options.num == 42);
91 
92     // Modified values, ie. given on the CLI, have precedence
93     // over config values:
94     options.file = "/path/from/cli";
95     options.num = 13;
96 
97     assert(options.file == "/path/from/cli");
98     assert(options.num == 13);
99 }
100 
101 /// String-type values equal to this string are considered empty.
102 enum configEmptyArgument = "-";
103 
104 /// Keys prefixed with this string are ignored.
105 enum configCommentPrefix = "//";
106 
107 /// Maximum size of a valid config file.
108 enum maxConfigSize = toBytes(256, SizeUnit.MiB);
109 
110 
111 /// Thrown if an error while handling config file occurs.
112 class ConfigFileException : Exception
113 {
114     string configKey;
115     Json configValue;
116 
117     /**
118         Params:
119             msg  = The message for the exception.
120             next = The previous exception in the chain of exceptions.
121             file = The file where the exception occurred.
122             line = The line number where the exception occurred.
123     */
124     this(string msg, Throwable next, string file = __FILE__,
125          size_t line = __LINE__) @nogc @safe pure nothrow
126     {
127         super(msg, file, line, next);
128     }
129 
130     /**
131         Params:
132             msg         = The message for the exception.
133             configKey   = Key of the erroneous config entry.
134             configValue = Value of the erroneous config entry.
135             file        = The file where the exception occurred.
136             line        = The line number where the exception occurred.
137             next        = The previous exception in the chain of exceptions, if any.
138     */
139     this(string msg, string configKey, Json configValue = Json.init, string file = __FILE__, size_t line = __LINE__,
140          Throwable next = null) @nogc @safe pure nothrow
141     {
142         super(msg, file, line, next);
143         this.configKey = configKey;
144         this.configValue = configValue;
145     }
146 
147     /**
148         Params:
149             msg         = The message for the exception.
150             configKey   = Key of the erroneous config entry.
151             configValue = Value of the erroneous config entry.
152             next        = The previous exception in the chain of exceptions.
153             file        = The file where the exception occurred.
154             line        = The line number where the exception occurred.
155     */
156     this(string msg, string configKey, Json configValue, Throwable next, string file = __FILE__,
157          size_t line = __LINE__) @nogc @safe pure nothrow
158     {
159         super(msg, file, line, next);
160         this.configKey = configKey;
161         this.configValue = configValue;
162     }
163 }
164 
165 T enforce(T)(
166     T value,
167     lazy string message,
168     lazy string configKey = null,
169     lazy Json configValue = Json.init,
170     string file = __FILE__,
171     size_t line = __LINE__,
172 )
173 {
174     static import std.exception;
175 
176     return std.exception.enforce(value, new ConfigFileException(
177         message,
178         configKey,
179         configValue,
180         file,
181         line,
182     ));
183 }
184 
185 
186 /// Retroactively initialize options from config.
187 Options retroInitFromConfig(Options)(ref Options options, in string configFile)
188 {
189     return retroInitFromConfig(options, parseConfig!Options(configFile));
190 }
191 
192 
193 /// ditto
194 Options retroInitFromConfig(Options)(ref Options options, in Json config)
195 {
196     import std.format : format;
197     import std.meta : Alias;
198     import std.traits :
199         getUDAs,
200         isArray,
201         isFloatingPoint,
202         isSomeString,
203         isSomeString,
204         isStaticArray;
205     import std.math : isNaN;
206 
207     enum defaultOptions = Options.init;
208     Options optionsFromConfig = parseConfig!Options(config);
209 
210     static foreach (member; __traits(allMembers, Options))
211     {{
212         alias symbol = Alias!(__traits(getMember, options, member));
213         enum isMemberAssignable = __traits(compiles,
214             __traits(getMember, options, member) = __traits(getMember, options, member)
215         );
216 
217         static if (isMemberAssignable)
218         {
219             alias Member = typeof(__traits(getMember, options, member));
220             enum unaryMixin(string template_) = format!template_(member);
221             enum binaryMixin(string template_) = format!template_(member, member);
222             alias assignConfigValue = () => mixin(binaryMixin!"options.%s = optionsFromConfig.%s");
223 
224             static if (getUDAs!(symbol, Argument).length > 0)
225             {
226                 static if (isSomeString!Member)
227                 {
228                     if (mixin(unaryMixin!"options.%s == configEmptyArgument"))
229                         assignConfigValue();
230                 }
231                 else static if (isArray!Member && isSomeString!(ElementType!Member))
232                 {
233                     if (mixin(unaryMixin!"options.%s.all!(v => v == configEmptyArgument)"))
234                         assignConfigValue();
235                 }
236             }
237             else
238             {
239                 static if (isStaticArray!Member || is(Member == class))
240                 {
241                     if (mixin(binaryMixin!"options.%s == defaultOptions.%s"))
242                         assignConfigValue();
243                 }
244                 else static if (isFloatingPoint!Member)
245                 {
246                     if (
247                         mixin(binaryMixin!"options.%s == defaultOptions.%s") ||
248                         (
249                             mixin(unaryMixin!"options.%s.isNaN") &&
250                             mixin(unaryMixin!"defaultOptions.%s.isNaN")
251                         )
252                     )
253                         assignConfigValue();
254                 }
255                 else
256                 {
257                     if (mixin(binaryMixin!"options.%s is defaultOptions.%s"))
258                         assignConfigValue();
259                 }
260             }
261         }
262     }}
263 
264     return options;
265 }
266 
267 
268 /// Initialize options using config.
269 Options parseConfig(Options)(in string configFile)
270 {
271     import vibe.data.json : parseJson;
272 
273     auto configContent = readConfigFile(configFile);
274     auto configValues = parseJson(
275         configContent,
276         null,
277         configFile,
278     );
279 
280     return parseConfig!Options(configValues);
281 }
282 
283 /// ditto
284 Options parseConfig(Options)(in Json config)
285 {
286     import std.meta : Alias;
287 
288     validateConfig!Options(config);
289 
290     Options options;
291 
292     foreach (member; __traits(allMembers, Options))
293     {
294         alias symbol = Alias!(__traits(getMember, options, member));
295         enum names = configNamesOf!symbol;
296 
297         static if (names.length > 0)
298         {
299             foreach (name; names)
300                 if (name in config)
301                     options.assignConfigValue!member(name, config[name]);
302         }
303     }
304 
305     return options;
306 }
307 
308 /// Validate config.
309 void validateConfigFile(Options)(in string configFile)
310 {
311     import vibe.data.json : parseJson;
312 
313     auto configContent = readConfigFile(configFile);
314     auto configValues = parseJson(
315         configContent,
316         null,
317         configFile,
318     );
319 
320     validateConfig!Options(configValues);
321 }
322 
323 /// ditto
324 void validateConfig(Options)(in Json config)
325 {
326     import std.algorithm : startsWith;
327     import std.format : format;
328     import std.meta : Alias;
329 
330     enforce(config.type == Json.Type.object, "config must contain a single object");
331 
332     configLoop: foreach (configKey, configValue; config.byKeyValue)
333     {
334         if (configKey.startsWith(configCommentPrefix))
335             continue;
336 
337         foreach (member; __traits(allMembers, Options))
338         {
339             alias symbol = Alias!(__traits(getMember, Options, member));
340             enum names = configNamesOf!symbol;
341 
342             static if (names.length > 0)
343             {
344                 alias SymbolType = typeof(__traits(getMember, Options, member));
345 
346                 foreach (name; names)
347                 {
348                     try
349                     {
350                         if (name == configKey)
351                         {
352                             cast(void) getConfigValue!SymbolType(configKey, configValue);
353                             continue configLoop;
354                         }
355                     }
356                     catch (Exception cause)
357                     {
358                         throw new ConfigFileException(
359                             format!"malformed config value `%s`: %s"(
360                                 configKey,
361                                 cause.msg,
362                             ),
363                             configKey,
364                             configValue,
365                             cause,
366                         );
367                     }
368                 }
369             }
370         }
371 
372         throw new ConfigFileException(
373             format!"invalid config key `%s`"(
374                 configKey,
375             ),
376             configKey,
377         );
378     }
379 }
380 
381 template configNamesOf(alias symbol)
382 {
383     import std.array : split;
384     import std.traits : getUDAs;
385 
386     alias optUDAs = getUDAs!(symbol, Option);
387     alias argUDAs = getUDAs!(symbol, Argument);
388 
389     static if (argUDAs.length > 0)
390         enum argName = argUDAs[0].name.split(":")[$ - 1][0 .. $ - 1];
391 
392     static if (optUDAs.length > 0 && argUDAs.length > 0)
393     {
394         enum configNamesOf = optUDAs[0].names ~ argName;
395     }
396     else static if (optUDAs.length > 0)
397     {
398         enum configNamesOf = optUDAs[0].names;
399     }
400     else static if (argUDAs.length > 0)
401     {
402         enum configNamesOf = [argName];
403     }
404     else
405     {
406         enum string[] configNamesOf = [];
407     }
408 }
409 
410 void assignConfigValue(string member, Options)(ref Options options, string configKey, Json configValue)
411 {
412     import std.conv : to;
413     import std.traits : isAssignable;
414 
415     alias SymbolType = typeof(__traits(getMember, options, member));
416 
417     static if (isOptionHandler!SymbolType)
418     {
419         if (configValue.type == Json.Type.int_)
420             foreach (i; 0 .. configValue.get!ulong)
421                 __traits(getMember, options, member)();
422         else if (configValue.type == Json.Type.bool_)
423         {
424             if (configValue.get!bool)
425                 __traits(getMember, options, member)();
426         }
427         else
428             throw new ConfigFileException(
429                 "Got JSON of type " ~ configValue.type.to!string ~
430                 ", expected int_ or bool_.",
431                 configKey,
432                 configValue,
433             );
434     }
435     else static if (isArgumentHandler!SymbolType)
436     {
437         if (configValue.type == Json.Type.array)
438             foreach (item; configValue.get!(Json[]))
439                 __traits(getMember, options, member)(item.get!string);
440         else if (configValue.type == Json.Type..string)
441             __traits(getMember, options, member)(configValue.get!string);
442         else
443             throw new ConfigFileException(
444                 "Got JSON of type " ~ configValue.type.to!string ~
445                 ", expected array or string_.",
446                 configKey,
447                 configValue,
448             );
449     }
450     else static if (isAssignable!SymbolType)
451     {
452         __traits(getMember, options, member) = getConfigValue!SymbolType(configKey, configValue);
453     }
454 }
455 
456 auto getConfigValue(SymbolType)(string configKey, Json configValue)
457 {
458     import std.conv : to;
459     import std.range.primitives : ElementType;
460     import std.traits :
461         isArray,
462         isDynamicArray,
463         isFloatingPoint,
464         isIntegral,
465         isSomeString,
466         isUnsigned;
467 
468     static if (is(SymbolType == OptionFlag))
469         return configValue.get!bool.to!SymbolType;
470     else static if (is(SymbolType == enum))
471         return configValue.get!string.to!SymbolType;
472     else static if (is(SymbolType == OptionFlag) || is(SymbolType : bool))
473         return configValue.get!bool.to!SymbolType;
474     else static if (isFloatingPoint!SymbolType)
475         return configValue.get!double.to!SymbolType;
476     else static if (isIntegral!SymbolType && isUnsigned!SymbolType)
477         return configValue.get!ulong.to!SymbolType;
478     else static if (isIntegral!SymbolType && !isUnsigned!SymbolType)
479         return configValue.get!long.to!SymbolType;
480     else static if (isSomeString!SymbolType)
481     {
482         if (configValue.type == Json.Type..string)
483             return configValue.get!string.to!SymbolType;
484         else if (configValue.type == Json.Type.null_)
485             return null;
486         else
487             throw new ConfigFileException(
488                 "Got JSON of type " ~ configValue.type.to!string ~
489                 ", expected string or null_.",
490                 configKey,
491                 configValue,
492             );
493     }
494     else static if (isArray!SymbolType)
495     {
496         SymbolType value;
497 
498         static if (isDynamicArray!SymbolType)
499             value.length = configValue.length;
500         else
501             enforce(
502                 configValue.length == value.length,
503                 "array must have " ~ value.length ~ " elements",
504                 configKey,
505                 configValue,
506             );
507 
508         foreach (size_t i, configElement; configValue.get!(Json[]))
509             value[i] = getConfigValue!(ElementType!SymbolType)(configKey, configElement);
510 
511         return value;
512     }
513 }
514 
515 string readConfigFile(in string configFileName)
516 {
517     import std.stdio : File;
518     import std.format : format;
519 
520     auto configFile = File(configFileName, "r");
521     auto configFileSize = configFile.size;
522 
523     enforce(
524         configFileSize <= maxConfigSize,
525         format!"config file is too large; must be <= %.2f %s"(fromBytes(maxConfigSize).expand),
526     );
527 
528     auto configContent = configFile.rawRead(new char[configFileSize]);
529 
530     return cast(string) configContent;
531 }
532 
533 /// Units for bytes.
534 enum SizeUnit
535 {
536     B,
537     KiB,
538     MiB,
539     GiB,
540     TiB,
541     PiB,
542     EiB,
543     ZiB,
544     YiB,
545 }
546 
547 /// Convert a value and unit to number of bytes.
548 auto toBytes(in size_t value, in SizeUnit unit)
549 {
550     return value * sizeUnitBase^^unit;
551 }
552 
553 /// Convert bytes to
554 auto fromBytes(in size_t bytes)
555 {
556     import std.conv : to;
557     import std.typecons : tuple;
558     import std.traits : EnumMembers;
559 
560     alias convertToUnit = exp => tuple!("value", "unit")(
561         bytes.to!double / (sizeUnitBase^^exp),
562         exp,
563     );
564 
565     foreach (exp; EnumMembers!SizeUnit)
566     {
567         if (bytes <= sizeUnitBase^^exp)
568             return convertToUnit(exp);
569     }
570 
571     return convertToUnit(SizeUnit.max);
572 }
573 
574 private enum size_t sizeUnitBase = 2^^10;