1 /*******************************************************************************
2 
3     Utilities to fill a struct representing the configuration with the content
4     of a YAML document.
5 
6     The main function of this module is `parseConfig`. Convenience functions
7     `parseConfigString` and `parseConfigFile` are also available.
8 
9     The type parameter to those three functions must be a struct and is used
10     to drive the processing of the YAML node. When an error is encountered,
11     an `Exception` will be thrown, with a descriptive message.
12     The rules by which the struct is filled are designed to be
13     as intuitive as possible, and are described below.
14 
15     Optional_Fields:
16       One of the major convenience offered by this utility is its handling
17       of optional fields. A field is detected as optional if it has
18       an initializer that is different from its type `init` value,
19       for example `string field = "Something";` is an optional field,
20       but `int count = 0;` is not.
21       To mark a field as optional even with its default value,
22       use the `Optional` UDA: `@Optional int count = 0;`.
23 
24     Converter:
25       Because config structs may contain complex types such as
26       a Phobos type, a user-defined `Amount`, or Vibe.d's `URL`,
27       one may need to apply a converter to a struct's field.
28       Converters are functions that take a YAML `Node` as argument
29       and return a type that is implicitly convertible to the field type
30       (usually just the field type). They offer the most power to users,
31       as they can inspect the YAML structure, but should be used as a last resort.
32 
33     Composite_Types:
34       Processing starts from a `struct` at the top level, and recurse into
35       every fields individually. If a field is itself a struct,
36       the filler will attempt the following, in order:
37       - If the field has no value and is not optional, an Exception will
38         be thrown with an error message detailing where the issue happened.
39       - If the field has no value and is optional, the default value will
40         be used.
41       - If the field has a value, the filler will first check for a converter
42         and use it if present.
43       - If the type has a `static` method named `fromString` whose sole argument
44         is a `string`, it will be used.
45       - If the type has a constructor whose sole argument is a `string`,
46         it will be used;
47       - Finally, the filler will attempt to deserialize all struct members
48         one by one and pass them to the default constructor, if there is any.
49       - If none of the above succeeded, a `static assert` will trigger.
50 
51     Alias_this:
52       If a `struct` contains an `alias this`, the field that is aliased will be
53       ignored, instead the config parser will parse nested fields as if they
54       were part of the enclosing structure. This allow to re-use a single `struct`
55       in multiple place without having to resort to a `mixin template`.
56       Having an initializer will make all fields in the aliased struct optional.
57       The aliased field cannot have attributes other than `@Optional`,
58       which will then apply to all fields it exposes.
59 
60     Duration_parsing:
61       If the config field is of type `core.time.Duration`, special parsing rules
62       will apply. There are two possible forms in which a Duration field may
63       be expressed. In the first form, the YAML node should be a mapping,
64       and it will be checked for fields matching the supported units
65       in `core.time`: `weeks`, `days`, `hours`, `minutes`, `seconds`, `msecs`,
66       `usecs`, `hnsecs`, `nsecs`. Strict parsing option will be respected.
67       The values of the fields will then be added together, so the following
68       YAML usages are equivalent:
69       ---
70       // sleepFor:
71       //   hours: 8
72       //   minutes: 30
73       ---
74       and:
75       ---
76       // sleepFor:
77       //   minutes: 510
78       ---
79       Provided that the definition of the field is:
80       ---
81       public Duration sleepFor;
82       ---
83 
84       In the second form, the field should have a suffix composed of an
85       underscore ('_'), followed by a unit name as defined in `core.time`.
86       This can be either the field name directly, or a name override.
87       The latter is recommended to avoid confusion when using the field in code.
88       In this form, the YAML node is expected to be a scalar.
89       So the previous example, using this form, would be expressed as:
90       ---
91       sleepFor_minutes: 510
92       ---
93       and the field definition should be one of those two:
94       ---
95       public @Name("sleepFor_minutes") Duration sleepFor; /// Prefer this
96       public Duration sleepFor_minutes; /// This works too
97       ---
98 
99       Those forms are mutually exclusive, so a field with a unit suffix
100       will error out if a mapping is used. This prevents surprises and ensures
101       that the error message, if any, is consistent across user input.
102 
103       To disable or change this behavior, one may use a `Converter` instead.
104 
105     Strict_Parsing:
106       When strict parsing is enabled, the config filler will also validate
107       that the YAML nodes do not contains entry which are not present in the
108       mapping (struct) being processed.
109       This can be useful to catch typos or outdated configuration options.
110 
111     Post_Validation:
112       Some configuration will require validation across multiple sections.
113       For example, two sections may be mutually exclusive as a whole,
114       or may have fields which are mutually exclusive with another section's
115       field(s). This kind of dependence is hard to account for declaratively,
116       and does not affect parsing. For this reason, the preferred way to
117       handle those cases is to define a `validate` member method on the
118       affected config struct(s), which will be called once
119       parsing for that mapping is completed.
120       If an error is detected, this method should throw an Exception.
121 
122     Enabled_or_disabled_field:
123       While most complex logic validation should be handled post-parsing,
124       some section may be optional by default, but if provided, will have
125       required fields. To support this use case, if a field with the name
126       `enabled` is present in a struct, the parser will first process it.
127       If it is `false`, the parser will not attempt to process the struct
128       further, and the other fields will have their default value.
129       Likewise, if a field named `disabled` exists, the struct will not
130       be processed if it is set to `true`.
131 
132     Copyright:
133         Copyright (c) 2019-2022 BOSAGORA Foundation
134         All rights reserved.
135 
136     License:
137         MIT License. See LICENSE for details.
138 
139 *******************************************************************************/
140 
141 module dub.internal.configy.Read;
142 
143 public import dub.internal.configy.Attributes;
144 public import dub.internal.configy.Exceptions : ConfigException;
145 import dub.internal.configy.Exceptions;
146 import dub.internal.configy.FieldRef;
147 import dub.internal.configy.Utils;
148 
149 import dub.internal.dyaml.exception;
150 import dub.internal.dyaml.node;
151 import dub.internal.dyaml.loader;
152 
153 import std.algorithm;
154 import std.conv;
155 import std.datetime;
156 import std.format;
157 import std.getopt;
158 import std.meta;
159 import std.range;
160 import std.traits;
161 import std.typecons : Nullable, nullable, tuple;
162 
163 static import core.time;
164 
165 // Dub-specific adjustments for output
166 import dub.internal.logging;
167 
168 /// Command-line arguments
169 public struct CLIArgs
170 {
171     /// Path to the config file
172     public string config_path = "config.yaml";
173 
174     /// Overrides for config options
175     public string[][string] overrides;
176 
177     /// Helper to add items to `overrides`
178     public void overridesHandler (string, string value)
179     {
180         import std.string;
181         const idx = value.indexOf('=');
182         if (idx < 0) return;
183         string k = value[0 .. idx], v = value[idx + 1 .. $];
184         if (auto val = k in this.overrides)
185             (*val) ~= v;
186         else
187             this.overrides[k] = [ v ];
188     }
189 
190     /***************************************************************************
191 
192         Parses the base command line arguments
193 
194         This can be composed with the program argument.
195         For example, consider a program which wants to expose a `--version`
196         switch, the definition could look like this:
197         ---
198         public struct ProgramCLIArgs
199         {
200             public CLIArgs base; // This struct
201 
202             public alias base this; // For convenience
203 
204             public bool version_; // Program-specific part
205         }
206         ---
207         Then, an application-specific configuration routine would be:
208         ---
209         public GetoptResult parse (ref ProgramCLIArgs clargs, ref string[] args)
210         {
211             auto r = clargs.base.parse(args);
212             if (r.helpWanted) return r;
213             return getopt(
214                 args,
215                 "version", "Print the application version, &clargs.version_");
216         }
217         ---
218 
219         Params:
220           args = The command line args to parse (parsed options will be removed)
221           passThrough = Whether to enable `config.passThrough` and
222                         `config.keepEndOfOptions`. `true` by default, to allow
223                         composability. If your program doesn't have other
224                         arguments, pass `false`.
225 
226         Returns:
227           The result of calling `getopt`
228 
229     ***************************************************************************/
230 
231     public GetoptResult parse (ref string[] args, bool passThrough = true)
232     {
233         return getopt(
234             args,
235             // `caseInsensitive` is the default, but we need something
236             // with the same type for the ternary
237             passThrough ? config.keepEndOfOptions : config.caseInsensitive,
238             // Also the default, same reasoning
239             passThrough ? config.passThrough : config.noPassThrough,
240             "config|c",
241                 "Path to the config file. Defaults to: " ~ this.config_path,
242                 &this.config_path,
243 
244             "override|O",
245                 "Override a config file value\n" ~
246                 "Example: -O foo.bar=true -o dns=1.1.1.1 -o dns=2.2.2.2\n" ~
247                 "Array values are additive, other items are set to the last override",
248                 &this.overridesHandler,
249         );
250     }
251 }
252 
253 /*******************************************************************************
254 
255     Attempt to read and process the config file at `path`, print any error
256 
257     This 'simple' overload of the more detailed `parseConfigFile` will attempt
258     to read the file at `path`, and return a `Nullable` instance of it.
259     If an error happens, either because the file isn't readable or
260     the configuration has an issue, a message will be printed to `stderr`,
261     with colors if the output is a TTY, and a `null` instance will be returned.
262 
263     The calling code can hence just read a config file via:
264     ```
265     int main ()
266     {
267         auto configN = parseConfigFileSimple!Config("config.yaml");
268         if (configN.isNull()) return 1; // Error path
269         auto config = configN.get();
270         // Rest of the program ...
271     }
272     ```
273     An overload accepting `CLIArgs args` also exists.
274 
275     Params:
276         path = Path of the file to read from
277         args = Command line arguments on which `parse` has been called
278         strict = Whether the parsing should reject unknown keys in the
279                  document, warn, or ignore them (default: `StrictMode.Error`)
280 
281     Returns:
282         An initialized `Config` instance if reading/parsing was successful;
283         a `null` instance otherwise.
284 
285 *******************************************************************************/
286 
287 public Nullable!T parseConfigFileSimple (T) (string path, StrictMode strict = StrictMode.Error)
288 {
289     return parseConfigFileSimple!(T)(CLIArgs(path), strict);
290 }
291 
292 
293 /// Ditto
294 public Nullable!T parseConfigFileSimple (T) (in CLIArgs args, StrictMode strict = StrictMode.Error)
295 {
296     try
297     {
298         Node root = Loader.fromFile(args.config_path).load();
299         return nullable(parseConfig!T(args, root, strict));
300     }
301     catch (ConfigException exc)
302     {
303         exc.printException();
304         return typeof(return).init;
305     }
306     catch (Exception exc)
307     {
308         // Other Exception type may be thrown by D-YAML,
309         // they won't include rich information.
310         logWarn("%s", exc.message());
311         return typeof(return).init;
312     }
313 }
314 
315 /*******************************************************************************
316 
317     Print an Exception, potentially with colors on
318 
319     Trusted because of `stderr` usage.
320 
321 *******************************************************************************/
322 
323 private void printException (scope ConfigException exc) @trusted
324 {
325     import dub.internal.logging;
326 
327     if (hasColors)
328         logWarn("%S", exc);
329     else
330         logWarn("%s", exc.message());
331 }
332 
333 /*******************************************************************************
334 
335     Parses the config file or string and returns a `Config` instance.
336 
337     Params:
338         cmdln = command-line arguments (containing the path to the config)
339         path = When parsing a string, the path corresponding to it
340         strict = Whether the parsing should reject unknown keys in the
341                  document, warn, or ignore them (default: `StrictMode.Error`)
342 
343     Throws:
344         `Exception` if parsing the config file failed.
345 
346     Returns:
347         `Config` instance
348 
349 *******************************************************************************/
350 
351 public T parseConfigFile (T) (in CLIArgs cmdln, StrictMode strict = StrictMode.Error)
352 {
353     Node root = Loader.fromFile(cmdln.config_path).load();
354     return parseConfig!T(cmdln, root, strict);
355 }
356 
357 /// ditto
358 public T parseConfigString (T) (string data, string path, StrictMode strict = StrictMode.Error)
359 {
360     CLIArgs cmdln = { config_path: path };
361     auto loader = Loader.fromString(data);
362     loader.name = path;
363     Node root = loader.load();
364     return parseConfig!T(cmdln, root, strict);
365 }
366 
367 /*******************************************************************************
368 
369     Process the content of the YAML document described by `node` into an
370     instance of the struct `T`.
371 
372     See the module description for a complete overview of this function.
373 
374     Params:
375       T = Type of the config struct to fill
376       cmdln = Command line arguments
377       node = The root node matching `T`
378       strict = Action to take when encountering unknown keys in the document
379 
380     Returns:
381       An instance of `T` filled with the content of `node`
382 
383     Throws:
384       If the content of `node` cannot satisfy the requirements set by `T`,
385       or if `node` contain extra fields and `strict` is `true`.
386 
387 *******************************************************************************/
388 
389 public T parseConfig (T) (
390     in CLIArgs cmdln, Node node, StrictMode strict = StrictMode.Error)
391 {
392     static assert(is(T == struct), "`" ~ __FUNCTION__ ~
393                   "` should only be called with a `struct` type as argument, not: `" ~
394                   fullyQualifiedName!T ~ "`");
395 
396     final switch (node.nodeID)
397     {
398     case NodeID.mapping:
399             dbgWrite("Parsing config '%s', strict: %s",
400                      fullyQualifiedName!T,
401                      strict == StrictMode.Warn ?
402                        strict.paint(Yellow) : strict.paintIf(!!strict, Green, Red));
403             return node.parseMapping!(StructFieldRef!T)(
404                 null, T.init, const(Context)(cmdln, strict), null);
405     case NodeID.sequence:
406     case NodeID.scalar:
407     case NodeID.invalid:
408         throw new TypeConfigException(node, "mapping (object)", "document root");
409     }
410 }
411 
412 /*******************************************************************************
413 
414     The behavior to have when encountering a field in YAML not present
415     in the config definition.
416 
417 *******************************************************************************/
418 
419 public enum StrictMode
420 {
421     /// Issue an error by throwing an `UnknownKeyConfigException`
422     Error  = 0,
423     /// Write a message to `stderr`, but continue processing the file
424     Warn   = 1,
425     /// Be silent and do nothing
426     Ignore = 2,
427 }
428 
429 /// Used to pass around configuration
430 package struct Context
431 {
432     ///
433     private CLIArgs cmdln;
434 
435     ///
436     private StrictMode strict;
437 }
438 
439 /*******************************************************************************
440 
441     Parse a mapping from `node` into an instance of `T`
442 
443     Params:
444       TLFR = Top level field reference for this mapping
445       node = The YAML node object matching the struct being read
446       path = The runtime path to this mapping, used for nested types
447       defaultValue = The default value to use for `T`, which can be different
448                      from `T.init` when recursing into fields with initializers.
449       ctx = A context where properties that need to be conserved during
450             recursion are stored
451       fieldDefaults = Default value for some fields, used for `Key` recursion
452 
453 *******************************************************************************/
454 private TLFR.Type parseMapping (alias TLFR)
455     (Node node, string path, auto ref TLFR.Type defaultValue,
456      in Context ctx, in Node[string] fieldDefaults)
457 {
458     static assert(is(TLFR.Type == struct), "`parseMapping` called with wrong type (should be a `struct`)");
459     assert(node.nodeID == NodeID.mapping, "Internal error: parseMapping shouldn't have been called");
460 
461     dbgWrite("%s: `parseMapping` called for '%s' (node entries: %s)",
462              TLFR.Type.stringof.paint(Cyan), path.paint(Cyan),
463              node.length.paintIf(!!node.length, Green, Red));
464 
465     static foreach (FR; FieldRefTuple!(TLFR.Type))
466     {
467         static if (FR.Name != FR.FieldName && hasMember!(TLFR.Type, FR.Name) &&
468                    !is(typeof(mixin("TLFR.Type.", FR.Name)) == function))
469             static assert (FieldRef!(TLFR.Type, FR.Name).Name != FR.Name,
470                            "Field `" ~ FR.FieldName ~ "` `@Name` attribute shadows field `" ~
471                            FR.Name ~ "` in `" ~ TLFR.Type.stringof ~ "`: Add a `@Name` attribute to `" ~
472                            FR.Name ~ "` or change that of `" ~ FR.FieldName ~ "`");
473     }
474 
475     if (ctx.strict != StrictMode.Ignore)
476     {
477         /// First, check that all the sections found in the mapping are present in the type
478         /// If not, the user might have made a typo.
479         immutable string[] fieldNames = [ FieldsName!(TLFR.Type) ];
480         immutable string[] patterns = [ Patterns!(TLFR.Type) ];
481     FIELD: foreach (const ref Node key, const ref Node value; node)
482         {
483             const k = key.as!string;
484             if (!fieldNames.canFind(k))
485             {
486                 foreach (p; patterns)
487                     if (k.startsWith(p))
488                         // Require length because `0` would match `canFind`
489                         // and we don't want to allow `$PATTERN-`
490                         if (k[p.length .. $].length > 1 && k[p.length] == '-')
491                             continue FIELD;
492 
493                 if (ctx.strict == StrictMode.Warn)
494                 {
495                     scope exc = new UnknownKeyConfigException(
496                         path, key.as!string, fieldNames, key.startMark());
497                     exc.printException();
498                 }
499                 else
500                     throw new UnknownKeyConfigException(
501                         path, key.as!string, fieldNames, key.startMark());
502             }
503         }
504     }
505 
506     const enabledState = node.isMappingEnabled!(TLFR.Type)(defaultValue);
507 
508     if (enabledState.field != EnabledState.Field.None)
509         dbgWrite("%s: Mapping is enabled: %s", TLFR.Type.stringof.paint(Cyan), (!!enabledState).paintBool());
510 
511     auto convertField (alias FR) ()
512     {
513         static if (FR.Name != FR.FieldName)
514             dbgWrite("Field name `%s` will use YAML field `%s`",
515                      FR.FieldName.paint(Yellow), FR.Name.paint(Green));
516         // Using exact type here matters: we could get a qualified type
517         // (e.g. `immutable(string)`) if the field is qualified,
518         // which causes problems.
519         FR.Type default_ = __traits(getMember, defaultValue, FR.FieldName);
520 
521         // If this struct is disabled, do not attempt to parse anything besides
522         // the `enabled` / `disabled` field.
523         if (!enabledState)
524         {
525             // Even this is too noisy
526             version (none)
527                 dbgWrite("%s: %s field of disabled struct, default: %s",
528                          path.paint(Cyan), "Ignoring".paint(Yellow), default_);
529 
530             static if (FR.Name == "enabled")
531                 return false;
532             else static if (FR.Name == "disabled")
533                 return true;
534             else
535                 return default_;
536         }
537 
538         if (auto ptr = FR.FieldName in fieldDefaults)
539         {
540             dbgWrite("Found %s (%s.%s) in `fieldDefaults`",
541                      FR.Name.paint(Cyan), path.paint(Cyan), FR.FieldName.paint(Cyan));
542 
543             if (ctx.strict && FR.FieldName in node)
544                 throw new ConfigExceptionImpl("'Key' field is specified twice", path, FR.FieldName, node.startMark());
545             return (*ptr).parseField!(FR)(path.addPath(FR.FieldName), default_, ctx)
546                 .dbgWriteRet("Using value '%s' from fieldDefaults for field '%s'",
547                              FR.FieldName.paint(Cyan));
548         }
549 
550         // This, `FR.Pattern`, and the field in `@Name` are special support for `dub`
551         static if (FR.Pattern)
552         {
553             static if (is(FR.Type : V[K], K, V))
554             {
555                 alias AAFieldRef = NestedFieldRef!(V, FR);
556                 static assert(is(K : string), "Key type should be string-like");
557             }
558             else
559                 static assert(0, "Cannot have pattern on non-AA field");
560 
561             AAFieldRef.Type[string] result;
562             foreach (pair; node.mapping)
563             {
564                 const key = pair.key.as!string;
565                 if (!key.startsWith(FR.Name))
566                     continue;
567                 string suffix = key[FR.Name.length .. $];
568                 if (suffix.length)
569                 {
570                     if (suffix[0] == '-') suffix = suffix[1 .. $];
571                     else continue;
572                 }
573 
574                 result[suffix] = pair.value.parseField!(AAFieldRef)(
575                     path.addPath(key), default_.get(key, AAFieldRef.Type.init), ctx);
576             }
577             bool hack = true;
578             if (hack) return result;
579         }
580 
581         if (auto ptr = FR.Name in node)
582         {
583             dbgWrite("%s: YAML field is %s in node%s",
584                      FR.Name.paint(Cyan), "present".paint(Green),
585                      (FR.Name == FR.FieldName ? "" : " (note that field name is overriden)").paint(Yellow));
586             return (*ptr).parseField!(FR)(path.addPath(FR.Name), default_, ctx)
587                 .dbgWriteRet("Using value '%s' from YAML document for field '%s'",
588                              FR.FieldName.paint(Cyan));
589         }
590 
591         dbgWrite("%s: Field is %s from node%s",
592                  FR.Name.paint(Cyan), "missing".paint(Red),
593                  (FR.Name == FR.FieldName ? "" : " (note that field name is overriden)").paint(Yellow));
594 
595         // A field is considered optional if it has an initializer that is different
596         // from its default value, or if it has the `Optional` UDA.
597         // In that case, just return this value.
598         static if (FR.Optional)
599             return default_
600                 .dbgWriteRet("Using default value '%s' for optional field '%s'", FR.FieldName.paint(Cyan));
601 
602         // The field is not present, but it could be because it is an optional section.
603         // For example, the section could be defined as:
604         // ---
605         // struct RequestLimit { size_t reqs = 100; }
606         // struct Config { RequestLimit limits; }
607         // ---
608         // In this case we need to recurse into `RequestLimit` to check if any
609         // of its field is required.
610         else static if (mightBeOptional!FR)
611         {
612             const npath = path.addPath(FR.Name);
613             string[string] aa;
614             return Node(aa).parseMapping!(FR)(npath, default_, ctx, null);
615         }
616         else
617             throw new MissingKeyException(path, FR.Name, node.startMark());
618     }
619 
620     FR.Type convert (alias FR) ()
621     {
622         static if (__traits(getAliasThis, TLFR.Type).length == 1 &&
623                    __traits(getAliasThis, TLFR.Type)[0] == FR.FieldName)
624         {
625             static assert(FR.Name == FR.FieldName,
626                           "Field `" ~ fullyQualifiedName!(FR.Ref) ~
627                           "` is the target of an `alias this` and cannot have a `@Name` attribute");
628             static assert(!hasConverter!(FR.Ref),
629                           "Field `" ~ fullyQualifiedName!(FR.Ref) ~
630                           "` is the target of an `alias this` and cannot have a `@Converter` attribute");
631 
632             alias convertW(string FieldName) = convert!(FieldRef!(FR.Type, FieldName, FR.Optional));
633             return FR.Type(staticMap!(convertW, FieldNameTuple!(FR.Type)));
634         }
635         else
636             return convertField!(FR)();
637     }
638 
639     debug (ConfigFillerDebug)
640     {
641         indent++;
642         scope (exit) indent--;
643     }
644 
645     TLFR.Type doValidation (TLFR.Type result)
646     {
647         static if (is(typeof(result.validate())))
648         {
649             if (enabledState)
650             {
651                 dbgWrite("%s: Calling `%s` method",
652                      TLFR.Type.stringof.paint(Cyan), "validate()".paint(Green));
653                 result.validate();
654             }
655             else
656             {
657                 dbgWrite("%s: Ignoring `%s` method on disabled mapping",
658                          TLFR.Type.stringof.paint(Cyan), "validate()".paint(Green));
659             }
660         }
661         else if (enabledState)
662             dbgWrite("%s: No `%s` method found",
663                      TLFR.Type.stringof.paint(Cyan), "validate()".paint(Yellow));
664 
665         return result;
666     }
667 
668     // This might trigger things like "`this` is not accessible".
669     // In this case, the user most likely needs to provide a converter.
670     alias convertWrapper(string FieldName) = convert!(FieldRef!(TLFR.Type, FieldName));
671     return doValidation(TLFR.Type(staticMap!(convertWrapper, FieldNameTuple!(TLFR.Type))));
672 }
673 
674 /*******************************************************************************
675 
676     Parse a field, trying to match up the compile-time expectation with
677     the run time value of the Node (`nodeID`).
678 
679     This is the central point which does "type conversion", from the YAML node
680     to the field type. Whenever adding support for a new type, things should
681     happen here.
682 
683     Because a `struct` can be filled from either a mapping or a scalar,
684     this function will first try the converter / fromString / string ctor
685     methods before defaulting to field-wise construction.
686 
687     Note that optional fields are checked before recursion happens,
688     so this method does not do this check.
689 
690 *******************************************************************************/
691 
692 package FR.Type parseField (alias FR)
693     (Node node, string path, auto ref FR.Type defaultValue, in Context ctx)
694 {
695     if (node.nodeID == NodeID.invalid)
696         throw new TypeConfigException(node, "valid", path);
697 
698     // If we reached this, it means the field is set, so just recurse
699     // to peel the type
700     static if (is(FR.Type : SetInfo!FT, FT))
701         return FR.Type(
702             parseField!(FieldRef!(FR.Type, "value"))(node, path, defaultValue, ctx),
703             true);
704 
705     else static if (hasConverter!(FR.Ref))
706         return wrapException(node.viaConverter!(FR)(path, ctx), path, node.startMark());
707 
708     else static if (hasFromYAML!(FR.Type))
709     {
710         scope impl = new ConfigParserImpl!(FR.Type)(node, path, ctx);
711         return wrapException(FR.Type.fromYAML(impl), path, node.startMark());
712     }
713 
714     else static if (hasFromString!(FR.Type))
715         return wrapException(FR.Type.fromString(node.as!string), path, node.startMark());
716 
717     else static if (hasStringCtor!(FR.Type))
718         return wrapException(FR.Type(node.as!string), path, node.startMark());
719 
720     else static if (is(immutable(FR.Type) == immutable(core.time.Duration)))
721     {
722         if (node.nodeID != NodeID.mapping)
723             throw new DurationTypeConfigException(node, path);
724         return node.parseMapping!(StructFieldRef!DurationMapping)(
725             path, DurationMapping.make(defaultValue), ctx, null).opCast!Duration;
726     }
727 
728     else static if (is(FR.Type == struct))
729     {
730         if (node.nodeID != NodeID.mapping)
731             throw new TypeConfigException(node, "mapping (object)", path);
732         return node.parseMapping!(FR)(path, defaultValue, ctx, null);
733     }
734 
735     // Handle string early as they match the sequence rule too
736     else static if (isSomeString!(FR.Type))
737         // Use `string` type explicitly because `Variant` thinks
738         // `immutable(char)[]` (aka `string`) and `immutable(char[])`
739         // (aka `immutable(string)`) are not compatible.
740         return node.parseScalar!(string)(path);
741     // Enum too, as their base type might be an array (including strings)
742     else static if (is(FR.Type == enum))
743         return node.parseScalar!(FR.Type)(path);
744 
745     else static if (is(FR.Type : E[K], E, K))
746     {
747         if (node.nodeID != NodeID.mapping)
748             throw new TypeConfigException(node, "mapping (associative array)", path);
749 
750         // Note: As of June 2022 (DMD v2.100.0), associative arrays cannot
751         // have initializers, hence their UX for config is less optimal.
752         return node.mapping().map!(
753                 (Node.Pair pair) {
754                     return tuple(
755                         pair.key.get!K,
756                         pair.value.parseField!(NestedFieldRef!(E, FR))(
757                             format("%s[%s]", path, pair.key.as!string), E.init, ctx));
758                 }).assocArray();
759 
760     }
761     else static if (is(FR.Type : E[], E))
762     {
763         static if (hasUDA!(FR.Ref, Key))
764         {
765             static assert(getUDAs!(FR.Ref, Key).length == 1,
766                           "`" ~ fullyQualifiedName!(FR.Ref) ~
767                           "` field shouldn't have more than one `Key` attribute");
768             static assert(is(E == struct),
769                           "Field `" ~ fullyQualifiedName!(FR.Ref) ~
770                           "` has a `Key` attribute, but is a sequence of `" ~
771                           fullyQualifiedName!E ~ "`, not a sequence of `struct`");
772 
773             string key = getUDAs!(FR.Ref, Key)[0].name;
774 
775             if (node.nodeID != NodeID.mapping && node.nodeID != NodeID.sequence)
776                 throw new TypeConfigException(node, "mapping (object) or sequence", path);
777 
778             if (node.nodeID == NodeID.mapping) return node.mapping().map!(
779                 (Node.Pair pair) {
780                     if (pair.value.nodeID != NodeID.mapping)
781                         throw new TypeConfigException(
782                             "sequence of " ~ pair.value.nodeTypeString(),
783                             "sequence of mapping (array of objects)",
784                             path, null, node.startMark());
785 
786                     return pair.value.parseMapping!(StructFieldRef!E)(
787                         path.addPath(pair.key.as!string),
788                         E.init, ctx, key.length ? [ key: pair.key ] : null);
789                 }).array();
790         }
791         if (node.nodeID != NodeID.sequence)
792             throw new TypeConfigException(node, "sequence (array)", path);
793 
794         // We pass `E.init` as default value as it is not going to be used:
795         // Either there is something in the YAML document, and that will be
796         // converted, or `sequence` will not iterate.
797         return node.sequence.enumerate.map!(
798             kv => kv.value.parseField!(NestedFieldRef!(E, FR))(
799                 format("%s[%s]", path, kv.index), E.init, ctx))
800             .array();
801     }
802     else
803     {
804         static assert (!is(FR.Type == union),
805                        "`union` are not supported. Use a converter instead");
806         return node.parseScalar!(FR.Type)(path);
807     }
808 }
809 
810 /// Parse a node as a scalar
811 private T parseScalar (T) (Node node, string path)
812 {
813     if (node.nodeID != NodeID.scalar)
814         throw new TypeConfigException(node, "scalar (value)", path);
815 
816     static if (is(T == enum))
817         return node.as!string.to!(T);
818     else
819         return node.as!(T);
820 }
821 
822 /*******************************************************************************
823 
824     Write a potentially throwing user-provided expression in ConfigException
825 
826     The user-provided hooks may throw (e.g. `fromString / the constructor),
827     and the error may or may not be clear. We can't do anything about a bad
828     message but we can wrap the thrown exception in a `ConfigException`
829     to provide the location in the yaml file where the error happened.
830 
831     Params:
832       exp = The expression that may throw
833       path = Path within the config file of the field
834       position = Position of the node in the YAML file
835       file = Call site file (otherwise the message would point to this function)
836       line = Call site line (see `file` reasoning)
837 
838     Returns:
839       The result of `exp` evaluation.
840 
841 *******************************************************************************/
842 
843 private T wrapException (T) (lazy T exp, string path, Mark position,
844     string file = __FILE__, size_t line = __LINE__)
845 {
846     try
847         return exp;
848     catch (ConfigException exc)
849         throw exc;
850     catch (Exception exc)
851         throw new ConstructionException(exc, path, position, file, line);
852 }
853 
854 /// Allows us to reuse parseMapping and strict parsing
855 private struct DurationMapping
856 {
857     public SetInfo!long weeks;
858     public SetInfo!long days;
859     public SetInfo!long hours;
860     public SetInfo!long minutes;
861     public SetInfo!long seconds;
862     public SetInfo!long msecs;
863     public SetInfo!long usecs;
864     public SetInfo!long hnsecs;
865     public SetInfo!long nsecs;
866 
867     private static DurationMapping make (Duration def) @safe pure nothrow @nogc
868     {
869         typeof(return) result;
870         auto fullSplit = def.split();
871         result.weeks = SetInfo!long(fullSplit.weeks, fullSplit.weeks != 0);
872         result.days = SetInfo!long(fullSplit.days, fullSplit.days != 0);
873         result.hours = SetInfo!long(fullSplit.hours, fullSplit.hours != 0);
874         result.minutes = SetInfo!long(fullSplit.minutes, fullSplit.minutes != 0);
875         result.seconds = SetInfo!long(fullSplit.seconds, fullSplit.seconds != 0);
876         result.msecs = SetInfo!long(fullSplit.msecs, fullSplit.msecs != 0);
877         result.usecs = SetInfo!long(fullSplit.usecs, fullSplit.usecs != 0);
878         result.hnsecs = SetInfo!long(fullSplit.hnsecs, fullSplit.hnsecs != 0);
879         // nsecs is ignored by split as it's not representable in `Duration`
880         return result;
881     }
882 
883     ///
884     public void validate () const @safe
885     {
886         // That check should never fail, as the YAML parser would error out,
887         // but better be safe than sorry.
888         foreach (field; this.tupleof)
889             if (field.set)
890                 return;
891 
892         throw new Exception(
893             "Expected at least one of the components (weeks, days, hours, " ~
894             "minutes, seconds, msecs, usecs, hnsecs, nsecs) to be set");
895     }
896 
897     ///  Allow conversion to a `Duration`
898     public Duration opCast (T : Duration) () const scope @safe pure nothrow @nogc
899     {
900         return core.time.weeks(this.weeks) + core.time.days(this.days) +
901             core.time.hours(this.hours) + core.time.minutes(this.minutes) +
902             core.time.seconds(this.seconds) + core.time.msecs(this.msecs) +
903             core.time.usecs(this.usecs) + core.time.hnsecs(this.hnsecs) +
904             core.time.nsecs(this.nsecs);
905     }
906 }
907 
908 /// Evaluates to `true` if we should recurse into the struct via `parseMapping`
909 private enum mightBeOptional (alias FR) = is(FR.Type == struct) &&
910     !is(immutable(FR.Type) == immutable(core.time.Duration)) &&
911     !hasConverter!(FR.Ref) && !hasFromString!(FR.Type) &&
912     !hasStringCtor!(FR.Type) && !hasFromYAML!(FR.Type);
913 
914 /// Convenience template to check for the presence of converter(s)
915 private enum hasConverter (alias Field) = hasUDA!(Field, Converter);
916 
917 /// Provided a field reference `FR` which is known to have at least one converter,
918 /// perform basic checks and return the value after applying the converter.
919 private auto viaConverter (alias FR) (Node node, string path, in Context context)
920 {
921     enum Converters = getUDAs!(FR.Ref, Converter);
922     static assert (Converters.length,
923                    "Internal error: `viaConverter` called on field `" ~
924                    FR.FieldName ~ "` with no converter");
925 
926     static assert(Converters.length == 1,
927                   "Field `" ~ FR.FieldName ~ "` cannot have more than one `Converter`");
928 
929     scope impl = new ConfigParserImpl!(FR.Type)(node, path, context);
930     return Converters[0].converter(impl);
931 }
932 
933 private final class ConfigParserImpl (T) : ConfigParser!T
934 {
935     private Node node_;
936     private string path_;
937     private const(Context) context_;
938 
939     /// Ctor
940     public this (Node n, string p, const Context c) scope @safe pure nothrow @nogc
941     {
942         this.node_ = n;
943         this.path_ = p;
944         this.context_ = c;
945     }
946 
947     public final override inout(Node) node () inout @safe pure nothrow @nogc
948     {
949         return this.node_;
950     }
951 
952     public final override string path () const @safe pure nothrow @nogc
953     {
954         return this.path_;
955     }
956 
957     protected final override const(Context) context () const @safe pure nothrow @nogc
958     {
959         return this.context_;
960     }
961 }
962 
963 /// Helper predicate
964 private template NameIs (string searching)
965 {
966     enum bool Pred (alias FR) = (searching == FR.Name);
967 }
968 
969 /// Returns whether or not the field has a `enabled` / `disabled` field,
970 /// and its value. If it does not, returns `true`.
971 private EnabledState isMappingEnabled (M) (Node node, auto ref M default_)
972 {
973     import std.meta : Filter;
974 
975     alias EMT = Filter!(NameIs!("enabled").Pred, FieldRefTuple!M);
976     alias DMT = Filter!(NameIs!("disabled").Pred, FieldRefTuple!M);
977 
978     static if (EMT.length)
979     {
980         static assert (DMT.length == 0,
981                        "`enabled` field `" ~ EMT[0].FieldName ~
982                        "` conflicts with `disabled` field `" ~ DMT[0].FieldName ~ "`");
983 
984         if (auto ptr = "enabled" in node)
985             return EnabledState(EnabledState.Field.Enabled, (*ptr).as!bool);
986         return EnabledState(EnabledState.Field.Enabled, __traits(getMember, default_, EMT[0].FieldName));
987     }
988     else static if (DMT.length)
989     {
990         if (auto ptr = "disabled" in node)
991             return EnabledState(EnabledState.Field.Disabled, (*ptr).as!bool);
992         return EnabledState(EnabledState.Field.Disabled, __traits(getMember, default_, DMT[0].FieldName));
993     }
994     else
995     {
996         return EnabledState(EnabledState.Field.None);
997     }
998 }
999 
1000 /// Return value of `isMappingEnabled`
1001 private struct EnabledState
1002 {
1003     /// Used to determine which field controls a mapping enabled state
1004     private enum Field
1005     {
1006         /// No such field, the mapping is considered enabled
1007         None,
1008         /// The field is named 'enabled'
1009         Enabled,
1010         /// The field is named 'disabled'
1011         Disabled,
1012     }
1013 
1014     /// Check if the mapping is considered enabled
1015     public bool opCast () const scope @safe pure @nogc nothrow
1016     {
1017         return this.field == Field.None ||
1018             (this.field == Field.Enabled && this.fieldValue) ||
1019             (this.field == Field.Disabled && !this.fieldValue);
1020     }
1021 
1022     /// Type of field found
1023     private Field field;
1024 
1025     /// Value of the field, interpretation depends on `field`
1026     private bool fieldValue;
1027 }
1028 
1029 /// Evaluates to `true` if `T` is a `struct` with a default ctor
1030 private enum hasFieldwiseCtor (T) = (is(T == struct) && is(typeof(() => T(T.init.tupleof))));
1031 
1032 /// Evaluates to `true` if `T` has a static method that is designed to work with this library
1033 private enum hasFromYAML (T) = is(typeof(T.fromYAML(ConfigParser!(T).init)) : T);
1034 
1035 /// Evaluates to `true` if `T` has a static method that accepts a `string` and returns a `T`
1036 private enum hasFromString (T) = is(typeof(T.fromString(string.init)) : T);
1037 
1038 /// Evaluates to `true` if `T` is a `struct` which accepts a single string as argument
1039 private enum hasStringCtor (T) = (is(T == struct) && is(typeof(T.__ctor)) &&
1040                                   Parameters!(T.__ctor).length == 1 &&
1041                                   is(typeof(() => T(string.init))));
1042 
1043 unittest
1044 {
1045     static struct Simple
1046     {
1047         int value;
1048         string otherValue;
1049     }
1050 
1051     static assert( hasFieldwiseCtor!Simple);
1052     static assert(!hasStringCtor!Simple);
1053 
1054     static struct PubKey
1055     {
1056         ubyte[] data;
1057 
1058         this (string hex) @safe pure nothrow @nogc{}
1059     }
1060 
1061     static assert(!hasFieldwiseCtor!PubKey);
1062     static assert( hasStringCtor!PubKey);
1063 
1064     static assert(!hasFieldwiseCtor!string);
1065     static assert(!hasFieldwiseCtor!int);
1066     static assert(!hasStringCtor!string);
1067     static assert(!hasStringCtor!int);
1068 }
1069 
1070 /// Convenience function to extend a YAML path
1071 private string addPath (string opath, string newPart)
1072 in(newPart.length)
1073 do {
1074     return opath.length ? format("%s.%s", opath, newPart) : newPart;
1075 }