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             file = The file where the exception occurred.
121             line = The line number where the exception occurred.
122             next = The previous exception in the chain of exceptions, if any.
123     */
124     this(string msg, string file = __FILE__, size_t line = __LINE__,
125          Throwable next = null) @nogc @safe pure nothrow
126     {
127         super(msg, file, line, next);
128     }
129 
130     /**
131         Params:
132             msg  = The message for the exception.
133             next = The previous exception in the chain of exceptions.
134             file = The file where the exception occurred.
135             line = The line number where the exception occurred.
136     */
137     this(string msg, Throwable next, string file = __FILE__,
138          size_t line = __LINE__) @nogc @safe pure nothrow
139     {
140         super(msg, file, line, next);
141     }
142 
143     /**
144         Params:
145             msg         = The message for the exception.
146             configKey   = Key of the erroneous config entry.
147             configValue = Value of the erroneous config entry.
148             file        = The file where the exception occurred.
149             line        = The line number where the exception occurred.
150             next        = The previous exception in the chain of exceptions, if any.
151     */
152     this(string msg, string configKey, Json configValue, string file = __FILE__, size_t line = __LINE__,
153          Throwable next = null) @nogc @safe pure nothrow
154     {
155         super(msg, file, line, next);
156         this.configKey = configKey;
157         this.configValue = configValue;
158     }
159 
160     /**
161         Params:
162             msg         = The message for the exception.
163             configKey   = Key of the erroneous config entry.
164             configValue = Value of the erroneous config entry.
165             next        = The previous exception in the chain of exceptions.
166             file        = The file where the exception occurred.
167             line        = The line number where the exception occurred.
168     */
169     this(string msg, string configKey, Json configValue, Throwable next, string file = __FILE__,
170          size_t line = __LINE__) @nogc @safe pure nothrow
171     {
172         super(msg, file, line, next);
173         this.configKey = configKey;
174         this.configValue = configValue;
175     }
176 }
177 
178 T enforce(T)(
179     T value,
180     lazy string message,
181     lazy string configKey = null,
182     lazy Json configValue = Json.init,
183     string file = __FILE__,
184     size_t line = __LINE__,
185 )
186 {
187     static import std.exception;
188 
189     return std.exception.enforce(value, new ConfigFileException(
190         message,
191         configKey,
192         configValue,
193         file,
194         line,
195     ));
196 }
197 
198 
199 /// Retroactively initialize options from config.
200 Options retroInitFromConfig(Options)(ref Options options, in string configFile)
201 {
202     return retroInitFromConfig(options, parseConfig(configFile));
203 }
204 
205 Options retroInitFromConfig(Options)(ref Options options, in Json config)
206 {
207     import std.format : format;
208     import std.meta : Alias;
209     import std.traits :
210         getUDAs,
211         isArray,
212         isFloatingPoint,
213         isSomeString,
214         isSomeString,
215         isStaticArray;
216 
217     enum defaultOptions = Options.init;
218     Options optionsFromConfig = parseConfig!Options(config);
219 
220     static foreach (member; __traits(allMembers, Options))
221     {{
222         alias symbol = Alias!(__traits(getMember, options, member));
223         enum isMemberAssignable = __traits(compiles,
224             __traits(getMember, options, member) = __traits(getMember, options, member)
225         );
226 
227         static if (isMemberAssignable)
228         {
229             alias Member = typeof(__traits(getMember, options, member));
230             enum unaryMixin(string template_) = format!template_(member);
231             enum binaryMixin(string template_) = format!template_(member, member);
232             alias assignConfigValue = () => mixin(binaryMixin!"options.%s = optionsFromConfig.%s");
233 
234             static if (getUDAs!(symbol, Argument).length > 0)
235             {
236                 static if (isSomeString!Member)
237                 {
238                     if (mixin(unaryMixin!"options.%s == configEmptyArgument"))
239                         assignConfigValue();
240                 }
241                 else static if (isArray!Member && isSomeString!(ElementType!Member))
242                 {
243                     if (mixin(unaryMixin!"options.%s.all!(v => v == configEmptyArgument)"))
244                         assignConfigValue();
245                 }
246             }
247             else
248             {
249                 static if (isStaticArray!Member || is(Member == class))
250                 {
251                     if (mixin(binaryMixin!"options.%s == defaultOptions.%s"))
252                         assignConfigValue();
253                 }
254                 else static if (isFloatingPoint!Member)
255                 {
256                     if (
257                         mixin(binaryMixin!"options.%s == defaultOptions.%s") ||
258                         (
259                             mixin(unaryMixin!"options.%s.isNaN") &&
260                             mixin(unaryMixin!"defaultOptions.%s.isNaN")
261                         )
262                     )
263                         assignConfigValue();
264                 }
265                 else
266                 {
267                     if (mixin(binaryMixin!"options.%s is defaultOptions.%s"))
268                         assignConfigValue();
269                 }
270             }
271         }
272     }}
273 
274     return options;
275 }
276 
277 
278 /// Initialize options using config.
279 Options parseConfig(Options)(in string configFile)
280 {
281     auto configContent = readConfigFile(configFile);
282     auto configValues = parseJson(
283         configContent,
284         null,
285         configFile,
286     );
287 
288     return parseConfig!Options(configValues);
289 }
290 
291 /// ditto
292 Options parseConfig(Options)(in Json config)
293 {
294     import std.meta : Alias;
295 
296     validateConfig!Options(config);
297 
298     Options options;
299 
300     foreach (member; __traits(allMembers, Options))
301     {
302         alias symbol = Alias!(__traits(getMember, options, member));
303         enum names = configNamesOf!symbol;
304 
305         static if (names.length > 0)
306         {
307             foreach (name; names)
308                 if (name in config)
309                     options.assignConfigValue!member(name, config[name]);
310         }
311     }
312 
313     return options;
314 }
315 
316 /// Validate config.
317 void validateConfigFile(Options)(in string configFile)
318 {
319     import vibe.data.json : parseJson;
320 
321     auto configContent = readConfigFile(configFile);
322     auto configValues = parseJson(
323         configContent,
324         null,
325         configFile,
326     );
327 
328     validateConfig!Options(configValues);
329 }
330 
331 /// ditto
332 void validateConfig(Options)(in Json config)
333 {
334     import std.algorithm : startsWith;
335     import std.format : format;
336     import std.meta : Alias;
337 
338     configLoop: foreach (configKey, configValue; config.byKeyValue)
339     {
340         if (configKey.startsWith(configCommentPrefix))
341             continue;
342 
343         foreach (member; __traits(allMembers, Options))
344         {
345             alias symbol = Alias!(__traits(getMember, Options, member));
346             enum names = configNamesOf!symbol;
347 
348             static if (names.length > 0)
349             {
350                 alias SymbolType = typeof(__traits(getMember, Options, member));
351 
352                 foreach (name; names)
353                 {
354                     try
355                     {
356                         if (name == configKey)
357                         {
358                             cast(void) getConfigValue!SymbolType(configKey, configValue);
359                             continue configLoop;
360                         }
361                     }
362                     catch (Exception cause)
363                     {
364                         throw new ConfigFileException(
365                             format!"malformed config value `%s`: %s"(
366                                 configKey,
367                                 cause.msg,
368                             ),
369                             configKey,
370                             configValue,
371                             cause,
372                         );
373                     }
374                 }
375             }
376         }
377 
378         throw new ConfigFileException(
379             format!"invalid config key `%s`"(
380                 configKey,
381             ),
382             configKey,
383         );
384     }
385 }
386 
387 template configNamesOf(alias symbol)
388 {
389     import std.array : split;
390     import std.traits : getUDAs;
391 
392     alias optUDAs = getUDAs!(symbol, Option);
393     alias argUDAs = getUDAs!(symbol, Argument);
394 
395     static if (argUDAs.length > 0)
396         enum argName = argUDAs[0].name.split(":")[$ - 1][0 .. $ - 1];
397 
398     static if (optUDAs.length > 0 && argUDAs.length > 0)
399     {
400         enum configNamesOf = optUDAs[0].names ~ argName;
401     }
402     else static if (optUDAs.length > 0)
403     {
404         enum configNamesOf = optUDAs[0].names;
405     }
406     else static if (argUDAs.length > 0)
407     {
408         enum configNamesOf = [argName];
409     }
410     else
411     {
412         enum string[] configNamesOf = [];
413     }
414 }
415 
416 void assignConfigValue(string member, Options)(ref Options options, string configKey, Json configValue)
417 {
418     import std.traits : isAssignable;
419 
420     alias SymbolType = typeof(__traits(getMember, options, member));
421 
422     static if (isOptionHandler!SymbolType)
423     {
424         if (configValue.type == Json.Type.int_)
425             foreach (i; 0 .. configValue.get!ulong)
426                 __traits(getMember, options, member)();
427         else if (configValue.type == Json.Type.bool_)
428         {
429             if (configValue.get!bool)
430                 __traits(getMember, options, member)();
431         }
432         else
433             throw new ConfigFileException(
434                 "Got JSON of type " ~ configValue.type.to!string ~
435                 ", expected int_ or bool_.",
436                 configKey,
437                 configValue,
438             );
439     }
440     else static if (isArgumentHandler!SymbolType)
441     {
442         if (configValue.type == Json.Type.array)
443             foreach (item; configValue.get!(Json[]))
444                 __traits(getMember, options, member)(item.get!string);
445         else if (configValue.type == Json.Type..string)
446             __traits(getMember, options, member)(configValue.get!string);
447         else
448             throw new ConfigFileException(
449                 "Got JSON of type " ~ configValue.type.to!string ~
450                 ", expected array or string_.",
451                 configKey,
452                 configValue,
453             );
454     }
455     else static if (isAssignable!SymbolType)
456     {
457         __traits(getMember, options, member) = getConfigValue!SymbolType(configKey, configValue);
458     }
459 }
460 
461 auto getConfigValue(SymbolType)(string configKey, Json configValue)
462 {
463     import std.conv : to;
464     import std.range.primitives : ElementType;
465     import std.traits :
466         isArray,
467         isDynamicArray,
468         isFloatingPoint,
469         isIntegral,
470         isSomeString,
471         isUnsigned;
472 
473     static if (is(SymbolType == OptionFlag))
474         return configValue.get!bool.to!SymbolType;
475     else static if (is(SymbolType == enum))
476         return configValue.get!string.to!SymbolType;
477     else static if (is(SymbolType == OptionFlag) || is(SymbolType : bool))
478         return configValue.get!bool.to!SymbolType;
479     else static if (isFloatingPoint!SymbolType)
480         return configValue.get!double.to!SymbolType;
481     else static if (isIntegral!SymbolType && isUnsigned!SymbolType)
482         return configValue.get!ulong.to!SymbolType;
483     else static if (isIntegral!SymbolType && !isUnsigned!SymbolType)
484         return configValue.get!long.to!SymbolType;
485     else static if (isSomeString!SymbolType)
486     {
487         if (configValue.type == Json.Type..string)
488             return configValue.get!string.to!SymbolType;
489         else if (configValue.type == Json.Type.null_)
490             return null;
491         else
492             throw new ConfigFileException(
493                 "Got JSON of type " ~ configValue.type.to!string ~
494                 ", expected string or null_.",
495                 configKey,
496                 configValue,
497             );
498     }
499     else static if (isArray!SymbolType)
500     {
501         SymbolType value;
502 
503         static if (isDynamicArray!SymbolType)
504             value.length = configValue.length;
505         else
506             enforce(
507                 configValue.length == value.length,
508                 "array must have " ~ value.length ~ " elements",
509                 configKey,
510                 configValue,
511             );
512 
513         foreach (size_t i, configElement; configValue.get!(Json[]))
514             value[i] = getConfigValue!(ElementType!SymbolType)(configKey, configElement);
515 
516         return value;
517     }
518 }
519 
520 string readConfigFile(in string configFileName)
521 {
522     import std.stdio : File;
523     import std.format : format;
524 
525     auto configFile = File(configFileName, "r");
526     auto configFileSize = configFile.size;
527 
528     enforce(
529         configFileSize <= maxConfigSize,
530         format!"config file is too large; must be <= %.2f %s"(fromBytes(maxConfigSize).expand),
531     );
532 
533     auto configContent = configFile.rawRead(new char[configFileSize]);
534 
535     return cast(string) configContent;
536 }
537 
538 /// Units for bytes.
539 enum SizeUnit
540 {
541     B,
542     KiB,
543     MiB,
544     GiB,
545     TiB,
546     PiB,
547     EiB,
548     ZiB,
549     YiB,
550 }
551 
552 /// Convert a value and unit to number of bytes.
553 auto toBytes(in size_t value, in SizeUnit unit)
554 {
555     return value * sizeUnitBase^^unit;
556 }
557 
558 /// Convert bytes to
559 auto fromBytes(in size_t bytes)
560 {
561     import std.conv : to;
562     import std.typecons : tuple;
563     import std.traits : EnumMembers;
564 
565     alias convertToUnit = exp => tuple!("value", "unit")(
566         bytes.to!double / (sizeUnitBase^^exp),
567         exp,
568     );
569 
570     foreach (exp; EnumMembers!SizeUnit)
571     {
572         if (bytes <= sizeUnitBase^^exp)
573             return convertToUnit(exp);
574     }
575 
576     return convertToUnit(SizeUnit.max);
577 }
578 
579 private enum size_t sizeUnitBase = 2^^10;