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 /// ditto
193 Options retroInitFromConfig(Options)(ref Options options, in Json config)
194 {
195     return retroInitFromConfig(options, parseConfig!Options(config));
196 }
197 
198 /// ditto
199 Options retroInitFromConfig(Options)(ref Options options, Options optionsFromConfig)
200 {
201     import std.algorithm : all;
202     import std.format : format;
203     import std.math : isNaN;
204     import std.meta : Alias;
205     import std.range.primitives : ElementType;
206     import std.traits :
207         getUDAs,
208         isArray,
209         isFloatingPoint,
210         isSomeString,
211         isSomeString,
212         isStaticArray;
213 
214     enum defaultOptions = Options.init;
215 
216     static foreach (member; __traits(allMembers, Options))
217     {{
218         alias symbol = Alias!(__traits(getMember, options, member));
219         enum isMemberAssignable = __traits(compiles,
220             __traits(getMember, options, member) = __traits(getMember, options, member)
221         );
222 
223         static if (isMemberAssignable)
224         {
225             alias Member = typeof(__traits(getMember, options, member));
226             enum unaryMixin(string template_) = format!template_(member);
227             enum binaryMixin(string template_) = format!template_(member, member);
228             alias assignConfigValue = () => mixin(binaryMixin!"options.%s = optionsFromConfig.%s");
229 
230             static if (getUDAs!(symbol, Argument).length > 0)
231             {
232                 static if (isSomeString!Member)
233                 {
234                     if (mixin(unaryMixin!"options.%s == configEmptyArgument"))
235                         assignConfigValue();
236                 }
237                 else static if (isArray!Member && isSomeString!(ElementType!Member))
238                 {
239                     if (mixin(unaryMixin!"options.%s.all!(v => v == configEmptyArgument)"))
240                         assignConfigValue();
241                 }
242             }
243             else
244             {
245                 static if (isStaticArray!Member || is(Member == class))
246                 {
247                     if (mixin(binaryMixin!"options.%s == defaultOptions.%s"))
248                         assignConfigValue();
249                 }
250                 else static if (isFloatingPoint!Member)
251                 {
252                     if (
253                         mixin(binaryMixin!"options.%s == defaultOptions.%s") ||
254                         (
255                             mixin(unaryMixin!"options.%s.isNaN") &&
256                             mixin(unaryMixin!"defaultOptions.%s.isNaN")
257                         )
258                     )
259                         assignConfigValue();
260                 }
261                 else
262                 {
263                     if (mixin(binaryMixin!"options.%s is defaultOptions.%s"))
264                         assignConfigValue();
265                 }
266             }
267         }
268     }}
269 
270     return options;
271 }
272 
273 
274 /// Initialize options using config.
275 Options parseConfig(Options)(in string configFile)
276 {
277     import vibe.data.json : parseJson;
278 
279     auto configContent = readConfigFile(configFile);
280     auto configValues = parseJson(
281         configContent,
282         null,
283         configFile,
284     );
285 
286     return parseConfig!Options(configValues);
287 }
288 
289 /// ditto
290 Options parseConfig(Options)(in Json config)
291 {
292     import std.meta : Alias;
293 
294     validateConfig!Options(config);
295 
296     Options options;
297 
298     foreach (member; __traits(allMembers, Options))
299     {
300         alias symbol = Alias!(__traits(getMember, options, member));
301         enum names = configNamesOf!symbol;
302 
303         static if (names.length > 0)
304         {
305             foreach (name; names)
306                 if (name in config)
307                     options.assignConfigValue!member(name, config[name]);
308         }
309     }
310 
311     return options;
312 }
313 
314 /// Validate config.
315 void validateConfigFile(Options)(in string configFile)
316 {
317     import vibe.data.json : parseJson;
318 
319     auto configContent = readConfigFile(configFile);
320     auto configValues = parseJson(
321         configContent,
322         null,
323         configFile,
324     );
325 
326     validateConfig!Options(configValues);
327 }
328 
329 /// ditto
330 void validateConfig(Options)(in Json config)
331 {
332     import std.algorithm : startsWith;
333     import std.format : format;
334     import std.meta : Alias;
335 
336     enforce(config.type == Json.Type.object, "config must contain a single object");
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.conv : to;
419     import std.traits : isAssignable;
420 
421     alias SymbolType = typeof(__traits(getMember, options, member));
422 
423     static if (isOptionHandler!SymbolType)
424     {
425         if (configValue.type == Json.Type.int_)
426             foreach (i; 0 .. configValue.get!ulong)
427                 __traits(getMember, options, member)();
428         else if (configValue.type == Json.Type.bool_)
429         {
430             if (configValue.get!bool)
431                 __traits(getMember, options, member)();
432         }
433         else
434             throw new ConfigFileException(
435                 "Got JSON of type " ~ configValue.type.to!string ~
436                 ", expected int_ or bool_.",
437                 configKey,
438                 configValue,
439             );
440     }
441     else static if (isArgumentHandler!SymbolType)
442     {
443         if (configValue.type == Json.Type.array)
444             foreach (item; configValue.get!(Json[]))
445                 __traits(getMember, options, member)(item.get!string);
446         else if (configValue.type == Json.Type..string)
447             __traits(getMember, options, member)(configValue.get!string);
448         else
449             throw new ConfigFileException(
450                 "Got JSON of type " ~ configValue.type.to!string ~
451                 ", expected array or string_.",
452                 configKey,
453                 configValue,
454             );
455     }
456     else static if (isAssignable!SymbolType)
457     {
458         __traits(getMember, options, member) = getConfigValue!SymbolType(configKey, configValue);
459     }
460 }
461 
462 auto getConfigValue(SymbolType)(string configKey, Json configValue)
463 {
464     import std.conv : to;
465     import std.range.primitives : ElementType;
466     import std.traits :
467         isArray,
468         isDynamicArray,
469         isFloatingPoint,
470         isIntegral,
471         isSomeString,
472         isUnsigned;
473 
474     static if (is(SymbolType == OptionFlag))
475         return configValue.get!bool.to!SymbolType;
476     else static if (is(SymbolType == enum))
477         return configValue.get!string.to!SymbolType;
478     else static if (is(SymbolType == OptionFlag) || is(SymbolType : bool))
479         return configValue.get!bool.to!SymbolType;
480     else static if (isFloatingPoint!SymbolType)
481     {
482         if (configValue.type == Json.Type.int_)
483             return configValue.get!long.to!SymbolType;
484         else if (configValue.type == Json.Type.float_)
485             return configValue.get!double.to!SymbolType;
486         else
487             throw new ConfigFileException(
488                 "Got JSON of type " ~ configValue.type.to!string ~
489                 ", expected int_ or float_.",
490                 configKey,
491                 configValue,
492             );
493     }
494     else static if (isIntegral!SymbolType && isUnsigned!SymbolType)
495         return configValue.get!ulong.to!SymbolType;
496     else static if (isIntegral!SymbolType && !isUnsigned!SymbolType)
497         return configValue.get!long.to!SymbolType;
498     else static if (isSomeString!SymbolType)
499     {
500         if (configValue.type == Json.Type..string)
501             return configValue.get!string.to!SymbolType;
502         else if (configValue.type == Json.Type.null_)
503             return null;
504         else
505             throw new ConfigFileException(
506                 "Got JSON of type " ~ configValue.type.to!string ~
507                 ", expected string or null_.",
508                 configKey,
509                 configValue,
510             );
511     }
512     else static if (isArray!SymbolType)
513     {
514         SymbolType value;
515 
516         static if (isDynamicArray!SymbolType)
517             value.length = configValue.length;
518         else
519             enforce(
520                 configValue.length == value.length,
521                 "array must have " ~ value.length ~ " elements",
522                 configKey,
523                 configValue,
524             );
525 
526         foreach (size_t i, configElement; configValue.get!(Json[]))
527             value[i] = getConfigValue!(ElementType!SymbolType)(configKey, configElement);
528 
529         return value;
530     }
531 }
532 
533 string readConfigFile(in string configFileName)
534 {
535     import std.stdio : File;
536     import std.format : format;
537 
538     auto configFile = File(configFileName, "r");
539     auto configFileSize = configFile.size;
540 
541     enforce(
542         configFileSize <= maxConfigSize,
543         format!"config file is too large; must be <= %.2f %s"(fromBytes(maxConfigSize).expand),
544     );
545 
546     auto configContent = configFile.rawRead(new char[configFileSize]);
547 
548     return cast(string) configContent;
549 }
550 
551 /// Units for bytes.
552 enum SizeUnit
553 {
554     B,
555     KiB,
556     MiB,
557     GiB,
558     TiB,
559     PiB,
560     EiB,
561     ZiB,
562     YiB,
563 }
564 
565 /// Convert a value and unit to number of bytes.
566 auto toBytes(in size_t value, in SizeUnit unit)
567 {
568     return value * sizeUnitBase^^unit;
569 }
570 
571 /// Convert bytes to
572 auto fromBytes(in size_t bytes)
573 {
574     import std.conv : to;
575     import std.typecons : tuple;
576     import std.traits : EnumMembers;
577 
578     alias convertToUnit = exp => tuple!("value", "unit")(
579         bytes.to!double / (sizeUnitBase^^exp),
580         exp,
581     );
582 
583     foreach (exp; EnumMembers!SizeUnit)
584     {
585         if (bytes <= sizeUnitBase^^exp)
586             return convertToUnit(exp);
587     }
588 
589     return convertToUnit(SizeUnit.max);
590 }
591 
592 private enum size_t sizeUnitBase = 2^^10;