1 /++
2     Various functions related to serialising and deserialising structs into/from
3     .ini-like files.
4 
5     Example:
6     ---
7     struct FooSettings
8     {
9         string fooasdf;
10         string bar;
11         string bazzzzzzz;
12         @Quoted flerrp;
13         double pi;
14     }
15 
16     FooSettings f;
17 
18     f.fooasdf = "foo";
19     f.bar = "bar";
20     f.bazzzzzzz = "baz";
21     f.flerrp = "hirr steff  ";
22     f.pi = 3.14159;
23 
24     enum fooSerialised =
25    `[Foo]
26     fooasdf foo
27     bar bar
28     bazzzzzzz baz
29     flerrp "hirr steff  "
30     pi 3.14159`;
31 
32     enum fooJustified =
33     `[Foo]
34     fooasdf                 foo
35     bar                     bar
36     bazzzzzzz               baz
37     flerrp                  "hirr steff  "
38     pi                      3.14159`;
39 
40     Appender!(char[]) sink;
41 
42     sink.serialise(f);
43     assert(sink.data.justifiedEntryValueText == fooJustified);
44 
45     FooSettings mirror;
46     deserialise(fooSerialised, mirror);
47     assert(mirror == f);
48 
49     FooSettings mirror2;
50     deserialise(fooJustified, mirror2);
51     assert(mirror2 == mirror);
52     ---
53 
54     Copyright: [JR](https://github.com/zorael)
55     License: [Boost Software License 1.0](https://www.boost.org/users/license.html)
56 
57     Authors:
58         [JR](https://github.com/zorael)
59  +/
60 module lu.serialisation;
61 
62 private:
63 
64 public:
65 
66 import lu.uda : CannotContainComments, Quoted, Separator, Unserialisable;
67 
68 
69 // serialise
70 /++
71     Convenience function to call [serialise] on several objects.
72 
73     Example:
74     ---
75     struct Foo
76     {
77         // ...
78     }
79 
80     struct Bar
81     {
82         // ...
83     }
84 
85     Foo foo;
86     Bar bar;
87 
88     Appender!(char[]) sink;
89 
90     sink.serialise(foo, bar);
91     assert(!sink.data.empty);
92     ---
93 
94     Params:
95         sink = Reference output range to write the serialised objects to (in
96             their .ini file-like format).
97         things = Variadic list of objects to serialise.
98  +/
99 void serialise(Sink, Things...)(auto ref Sink sink, auto ref Things things)
100 if (Things.length > 1)
101 {
102     import std.meta : allSatisfy;
103     import std.range.primitives : isOutputRange;
104     import std.traits : isAggregateType;
105 
106     static if (!isOutputRange!(Sink, char[]))
107     {
108         enum message = "`serialise` sink must be an output range accepting `char[]`";
109         static assert(0, message);
110     }
111 
112     static if (!allSatisfy!(isAggregateType, Things))
113     {
114         enum message = "`serialise` was passed one or more non-aggregate types";
115         static assert(0, message);
116     }
117 
118     foreach (immutable i, const thing; things)
119     {
120         if (i > 0) sink.put('\n');
121         sink.serialise(thing);
122     }
123 }
124 
125 
126 // serialise
127 /++
128     Serialises the fields of an object into an .ini file-like format.
129 
130     It only serialises fields not annotated with
131     [lu.uda.Unserialisable|Unserialisable], and it doesn't recurse into other
132     structs or classes.
133 
134     Example:
135     ---
136     struct Foo
137     {
138         // ...
139     }
140 
141     Foo foo;
142 
143     Appender!(char[]) sink;
144 
145     sink.serialise(foo);
146     assert(!sink.data.empty);
147     ---
148 
149     Params:
150         sink = Reference output range to write to, usually an
151             [std.array.Appender|Appender].
152         thing = Object to serialise.
153  +/
154 void serialise(Sink, QualThing)(auto ref Sink sink, auto ref QualThing thing)
155 {
156     import lu.string : stripSuffix;
157     import std.format : format, formattedWrite;
158     import std.meta : allSatisfy;
159     import std.range.primitives : isOutputRange;
160     import std.traits : Unqual, isAggregateType;
161 
162     static if (!isOutputRange!(Sink, char[]))
163     {
164         enum message = "`serialise` sink must be an output range accepting `char[]`";
165         static assert(0, message);
166     }
167 
168     static if (!allSatisfy!(isAggregateType, QualThing))
169     {
170         enum message = "`serialise` was passed one or more non-aggregate types";
171         static assert(0, message);
172     }
173 
174     static if (__traits(hasMember, Sink, "data"))
175     {
176         // Sink is not empty, place a newline between current content and new
177         if (sink.data.length) sink.put("\n");
178     }
179 
180     alias Thing = Unqual!QualThing;
181 
182     sink.formattedWrite("[%s]\n", Thing.stringof.stripSuffix("Settings"));
183 
184     foreach (immutable i, member; thing.tupleof)
185     {
186         import lu.traits : isSerialisable, udaIndexOf;
187         import lu.uda : Separator, Unserialisable;
188         import std.traits : isAggregateType;
189 
190         alias T = Unqual!(typeof(member));
191 
192         static if (
193             isSerialisable!member &&
194             (udaIndexOf!(thing.tupleof[i], Unserialisable) == -1) &&
195             !isAggregateType!T)
196         {
197             import std.traits : isArray, isSomeString;
198 
199             enum memberstring = __traits(identifier, thing.tupleof[i]);
200 
201             static if (!isSomeString!T && isArray!T)
202             {
203                 import lu.traits : UnqualArray;
204                 import std.traits : getUDAs;
205 
206                 static if (udaIndexOf!(thing.tupleof[i], Separator) != -1)
207                 {
208                     alias separators = getUDAs!(thing.tupleof[i], Separator);
209                     enum separator = separators[0].token;
210 
211                     static if (!separator.length)
212                     {
213                         enum pattern = "`%s.%s` is annotated with an invalid `Separator` (empty)";
214                         static assert(0, pattern.format(Thing.stringof, memberstring));
215                     }
216                 }
217                 else static if ((__VERSION__ >= 2087L) && (udaIndexOf!(thing.tupleof[i], string) != -1))
218                 {
219                     alias separators = getUDAs!(thing.tupleof[i], string);
220                     enum separator = separators[0];
221 
222                     static if (!separator.length)
223                     {
224                         enum pattern = "`%s.%s` is annotated with an empty separator string";
225                         static assert(0, pattern.format(Thing.stringof, memberstring));
226                     }
227                 }
228                 else
229                 {
230                     enum pattern = "`%s.%s` is not annotated with a `Separator`";
231                     static assert (0, pattern.format(Thing.stringof, memberstring));
232                 }
233 
234                 alias TA = UnqualArray!(typeof(member));
235 
236                 enum arrayPattern = "%-(%s" ~ separator ~ "%)";
237                 enum escapedSeparator = '\\' ~ separator;
238 
239                 SerialisationUDAs udas;
240                 udas.separator = separator;
241                 udas.arrayPattern = arrayPattern;
242                 udas.escapedSeparator = escapedSeparator;
243 
244                 immutable value = serialiseArrayImpl!TA(thing.tupleof[i], udas);
245             }
246             else static if (is(T == enum))
247             {
248                 import lu.conv : Enum;
249                 immutable value = Enum!T.toString(member);
250             }
251             else
252             {
253                 auto value = member;
254             }
255 
256             import std.range : hasLength;
257 
258             static if (is(T == bool) || is(T == enum))
259             {
260                 enum comment = false;
261             }
262             else static if (is(T == float) || is(T == double))
263             {
264                 import std.conv : to;
265                 import std.math : isNaN;
266                 immutable comment = member.to!T.isNaN;
267             }
268             else static if (hasLength!T || isSomeString!T)
269             {
270                 immutable comment = !member.length;
271             }
272             else
273             {
274                 immutable comment = (member == T.init);
275             }
276 
277             if (i > 0) sink.put('\n');
278 
279             if (comment)
280             {
281                 // .init or otherwise disabled
282                 sink.put("#" ~ memberstring);
283             }
284             else
285             {
286                 import lu.uda : Quoted;
287 
288                 static if (isSomeString!T && (udaIndexOf!(thing.tupleof[i], Quoted) != -1))
289                 {
290                     enum pattern = `%s "%s"`;
291                 }
292                 else
293                 {
294                     enum pattern = "%s %s";
295                 }
296 
297                 sink.formattedWrite(pattern, memberstring, value);
298             }
299         }
300     }
301 
302     static if (!__traits(hasMember, Sink, "data"))
303     {
304         // Not an Appender, may be stdout.lockingTextWriter
305         sink.put('\n');
306     }
307 }
308 
309 ///
310 unittest
311 {
312     import lu.uda : Separator, Quoted;
313     import std.array : Appender;
314 
315     struct FooSettings
316     {
317         string fooasdf = "foo 1";
318         string bar = "foo 1";
319         string bazzzzzzz = "foo 1";
320         @Quoted flerrp = "hirr steff  ";
321         double pi = 3.14159;
322         @Separator(",") int[] arr = [ 1, 2, 3 ];
323         @Separator(";") string[] harbl = [ "harbl;;", ";snarbl;", "dirp" ];
324 
325         static if (__VERSION__ >= 2087L)
326         {
327             @("|") string[] matey = [ "a", "b", "c" ];
328         }
329     }
330 
331     struct BarSettings
332     {
333         string foofdsa = "foo 2";
334         string bar = "bar 2";
335         string bazyyyyyyy = "baz 2";
336         @Quoted flarrp = "   hirrsteff";
337         double pipyon = 3.0;
338     }
339 
340     static if (__VERSION__ >= 2087L)
341     {
342         enum fooSerialised =
343 `[Foo]
344 fooasdf foo 1
345 bar foo 1
346 bazzzzzzz foo 1
347 flerrp "hirr steff  "
348 pi 3.14159
349 arr 1,2,3
350 harbl harbl\;\;;\;snarbl\;;dirp
351 matey a|b|c`;
352     }
353     else
354     {
355         enum fooSerialised =
356 `[Foo]
357 fooasdf foo 1
358 bar foo 1
359 bazzzzzzz foo 1
360 flerrp "hirr steff  "
361 pi 3.14159
362 arr 1,2,3
363 harbl harbl\;\;;\;snarbl\;;dirp`;
364     }
365 
366     Appender!(char[]) fooSink;
367     fooSink.reserve(64);
368 
369     fooSink.serialise(FooSettings.init);
370     assert((fooSink.data == fooSerialised), '\n' ~ fooSink.data);
371 
372     enum barSerialised =
373 `[Bar]
374 foofdsa foo 2
375 bar bar 2
376 bazyyyyyyy baz 2
377 flarrp "   hirrsteff"
378 pipyon 3`;
379 
380     Appender!(char[]) barSink;
381     barSink.reserve(64);
382 
383     barSink.serialise(BarSettings.init);
384     assert((barSink.data == barSerialised), '\n' ~ barSink.data);
385 
386     // try two at once
387     Appender!(char[]) bothSink;
388     bothSink.reserve(128);
389     bothSink.serialise(FooSettings.init, BarSettings.init);
390     assert(bothSink.data == fooSink.data ~ "\n\n" ~ barSink.data);
391 
392     class C
393     {
394         int i;
395         bool b;
396     }
397 
398     C c = new C;
399     c.i = 42;
400     c.b = true;
401 
402     enum cSerialised =
403 `[C]
404 i 42
405 b true`;
406 
407     Appender!(char[]) cSink;
408     cSink.reserve(128);
409     cSink.serialise(c);
410     assert((cSink.data == cSerialised), '\n' ~ cSink.data);
411 
412     enum Letters { abc, def, ghi, }
413 
414     struct Struct
415     {
416         Letters let = Letters.def;
417     }
418 
419     enum enumTestSerialised =
420 `[Struct]
421 let def`;
422 
423     Struct st;
424     Appender!(char[]) enumTestSink;
425     enumTestSink.serialise(st);
426     assert((enumTestSink.data == enumTestSerialised), '\n' ~ enumTestSink.data);
427 }
428 
429 
430 // SerialisationUDAs
431 /++
432     Summary of UDAs that an array to be serialised is annotated with.
433 
434     UDAs do not persist across function calls, so they must be summarised
435     (such as in a struct like this) and separately passed, at compile-time or runtime.
436  +/
437 private struct SerialisationUDAs
438 {
439     /++
440         Whether or not the member was annotated [lu.uda.Unserialisable|Unserialisable].
441      +/
442     bool unserialisable;
443 
444     /++
445         Whether or not the member was annotated with a [lu.uda.Separator|Separator].
446      +/
447     string separator;
448 
449     /++
450         The escaped form of [separator].
451 
452         ---
453         enum escapedSeparator = '\\' ~ separator;
454         ---
455      +/
456     string escapedSeparator;
457 
458     /++
459         The [std.format.format|format] pattern used to format the array this struct
460         refers to. This is separator-specific.
461 
462         ---
463         enum arrayPattern = "%-(%s" ~ separator ~ "%)";
464         ---
465      +/
466     string arrayPattern;
467 }
468 
469 
470 // serialiseArrayImpl
471 /++
472     Serialises a non-string array into a single row. To be used when serialising
473     an aggregate with [serialise].
474 
475     Since UDAs do not persist across function calls, they must be summarised
476     in a [SerialisationUDAs] struct separately so we can pass them at runtime.
477 
478     Params:
479         array = Array to serialise.
480         udas = Aggregate of UDAs the original array was annotated with, passed as
481             a runtime value.
482 
483     Returns:
484         A string, to be saved as a serialised row in an .ini file-like format.
485  +/
486 private string serialiseArrayImpl(T)(const auto ref T array, const SerialisationUDAs udas)
487 {
488     import std.format : format;
489 
490     static if (is(T == string[]))
491     {
492         /+
493             Strings must be formatted differently since the specified separator
494             can occur naturally in the string.
495          +/
496         string value;
497 
498         if (array.length)
499         {
500             import std.algorithm.iteration : map;
501             import std.array : replace;
502 
503             enum placeholder = "\0\0";  // anything really
504 
505             // Replace separator with a placeholder and flatten with format
506             // enum arrayPattern = "%-(%s" ~ separator ~ "%)";
507 
508             auto separatedElements = array.map!(a => a.replace(udas.separator, placeholder));
509             value = udas.arrayPattern
510                 .format(separatedElements)
511                 .replace(placeholder, udas.escapedSeparator);
512         }
513     }
514     else
515     {
516         immutable value = udas.arrayPattern.format(array);
517     }
518 
519     return value;
520 }
521 
522 
523 @safe:
524 
525 
526 // deserialise
527 /++
528     Takes an input range containing serialised entry-value text and applies the
529     contents therein to one or more passed struct/class objects.
530 
531     Example:
532     ---
533     struct Foo
534     {
535         // ...
536     }
537 
538     struct Bar
539     {
540         // ...
541     }
542 
543     Foo foo;
544     Bar bar;
545 
546     string[][string] missingEntries;
547     string[][string] invalidEntries;
548 
549     string fromFile = readText("configuration.conf");
550 
551     fromFile
552         .splitter("\n")
553         .deserialise(missingEntries, invalidEntries, foo, bar);
554     ---
555 
556     Params:
557         range = Input range from which to read the serialised text.
558         missingEntries = Out reference of an associative array of string arrays
559             of expected entries that were missing.
560         invalidEntries = Out reference of an associative array of string arrays
561             of unexpected entries that did not belong.
562         things = Reference variadic list of one or more objects to apply the
563             deserialised values to.
564 
565     Throws: [DeserialisationException] if there were bad lines.
566  +/
567 void deserialise(Range, Things...)
568     (auto ref Range range,
569     out string[][string] missingEntries,
570     out string[][string] invalidEntries,
571     ref Things things) pure
572 {
573     import lu.string : stripSuffix, stripped;
574     import lu.traits : isSerialisable, udaIndexOf;
575     import lu.uda : Unserialisable;
576     import std.format : format;
577     import std.meta : allSatisfy;
578     import std.traits : Unqual, isAggregateType, isMutable;
579 
580     static if (!allSatisfy!(isAggregateType, Things))
581     {
582         enum message = "`deserialise` was passed one or more non-aggregate types";
583         static assert(0, message);
584     }
585 
586     static if (!allSatisfy!(isMutable, Things))
587     {
588         enum message = "`serialise` was passed one or more non-mutable types";
589         static assert(0, message);
590     }
591 
592     string section;
593     bool[Things.length] processedThings;
594     bool[string][string] encounteredOptions;
595 
596     // Populate `encounteredOptions` with all the options in `Things`, but
597     // set them to false. Flip to true when we encounter one.
598     foreach (immutable i, thing; things)
599     {
600         alias Thing = Unqual!(typeof(thing));
601 
602         static foreach (immutable n; 0..things[i].tupleof.length)
603         {{
604             static if (
605                 isSerialisable!(Things[i].tupleof[n]) &&
606                 (udaIndexOf!(things[i].tupleof[n], Unserialisable) == -1))
607             {
608                 enum memberstring = __traits(identifier, Things[i].tupleof[n]);
609                 encounteredOptions[Thing.stringof][memberstring] = false;
610             }
611         }}
612     }
613 
614     lineloop:
615     foreach (const rawline; range)
616     {
617         string line = rawline.stripped;  // mutable
618         if (!line.length) continue;
619 
620         bool commented;
621 
622         switch (line[0])
623         {
624         case '#':
625         case ';':
626             // Comment
627             if (!section.length) continue;  // e.g. banner
628 
629             while (line.length && ((line[0] == '#') || (line[0] == ';') || (line[0] == '/')))
630             {
631                 line = line[1..$];
632             }
633 
634             if (!line.length) continue;
635 
636             commented = true;
637             goto default;
638 
639         case '/':
640             if ((line.length > 1) && (line[1] == '/'))
641             {
642                 // Also a comment; //
643                 line = line[2..$];
644             }
645 
646             while (line.length && (line[0] == '/'))
647             {
648                 // Consume extra slashes too
649                 line = line[1..$];
650             }
651 
652             if (!line.length) continue;
653 
654             commented = true;
655             goto default;
656 
657         case '[':
658             // New section. Check if there's still something to do
659             immutable sectionBackup = line;
660             bool stillSomethingToProcess;
661 
662             static foreach (immutable size_t i; 0..Things.length)
663             {
664                 stillSomethingToProcess |= !processedThings[i];
665             }
666 
667             if (!stillSomethingToProcess) break lineloop;  // All done, early break
668 
669             try
670             {
671                 import std.format : formattedRead;
672                 line.formattedRead("[%s]", section);
673             }
674             catch (Exception e)
675             {
676                 throw new DeserialisationException("Malformed section header \"%s\", %s"
677                     .format(sectionBackup, e.msg));
678             }
679             continue;
680 
681         default:
682             // entry-value line
683             if (!section.length)
684             {
685                 throw new DeserialisationException("Sectionless orphan \"%s\""
686                     .format(line));
687             }
688 
689             //thingloop:
690             foreach (immutable i, thing; things)
691             {
692                 import lu.string : strippedLeft;
693                 import lu.traits : isSerialisable;
694                 import lu.uda : CannotContainComments;
695                 import std.traits : Unqual;
696 
697                 alias T = Unqual!(typeof(thing));
698                 enum settingslessT = T.stringof.stripSuffix("Settings").idup;
699 
700                 if (section != settingslessT) continue; // thingloop;
701                 processedThings[i] = true;
702 
703                 immutable result = splitEntryValue(line.strippedLeft);
704                 immutable entry = result.entry;
705                 if (!entry.length) continue;
706 
707                 string value = result.value;  // mutable for later slicing
708 
709                 switch (entry)
710                 {
711                 static foreach (immutable n; 0..things[i].tupleof.length)
712                 {{
713                     static if (
714                         isSerialisable!(Things[i].tupleof[n]) &&
715                         (udaIndexOf!(things[i].tupleof[n], Unserialisable) == -1))
716                     {
717                         enum memberstring = __traits(identifier, Things[i].tupleof[n]);
718 
719                         case memberstring:
720                             import lu.objmanip : setMemberByName;
721 
722                             if (!commented)
723                             {
724                                 // Entry is uncommented; set
725 
726                                 static if (udaIndexOf!(things[i].tupleof[n], CannotContainComments) != -1)
727                                 {
728                                     cast(void)things[i].setMemberByName(entry, value);
729                                 }
730                                 else
731                                 {
732                                     import lu.string : advancePast;
733                                     import std.string : indexOf;
734 
735                                     // Slice away any comments
736                                     value = (value.indexOf('#') != -1)  ? value.advancePast('#')  : value;
737                                     value = (value.indexOf(';') != -1)  ? value.advancePast(';')  : value;
738                                     value = (value.indexOf("//") != -1) ? value.advancePast("//") : value;
739                                     cast(void)things[i].setMemberByName(entry, value);
740                                 }
741                             }
742 
743                             encounteredOptions[Unqual!(Things[i]).stringof][memberstring] = true;
744                             continue lineloop;
745                     }
746                 }}
747 
748                 default:
749                     // Unknown setting in known section
750                     if (!commented) invalidEntries[section] ~= entry.length ? entry : line;
751                     break;
752                 }
753             }
754 
755             break;
756         }
757     }
758 
759     // Compose missing entries and save them as arrays in `missingEntries`.
760     foreach (immutable encounteredSection, const entryMatches; encounteredOptions)
761     {
762         foreach (immutable entry, immutable encountered; entryMatches)
763         {
764             immutable sectionName = encounteredSection.stripSuffix("Settings");
765             if (!encountered) missingEntries[sectionName] ~= entry;
766         }
767     }
768 }
769 
770 ///
771 unittest
772 {
773     import lu.uda : Separator;
774     import std.algorithm.iteration : splitter;
775     import std.conv : text;
776 
777     struct FooSettings
778     {
779         enum Bar { blaawp = 5, oorgle = -1 }
780         int i;
781         string s;
782         bool b;
783         float f;
784         double d;
785         Bar bar;
786         string commented;
787         string slashed;
788         int missing;
789         //bool invalid;
790 
791         @Separator(",")
792         {
793             int[] ia;
794             string[] sa;
795             bool[] ba;
796             float[] fa;
797             double[] da;
798             Bar[] bara;
799         }
800     }
801 
802     enum serialisedFileContents =
803 `[Foo]
804 i       42
805 ia      1,2,-3,4,5
806 s       hello world!
807 sa      hello,world,!
808 b       true
809 ba      true,false,true
810 invalid name
811 
812 # comment
813 ; other type of comment
814 // third type of comment
815 
816 f       3.14 #hirp
817 fa      0.0,1.1,-2.2,3.3 ;herp
818 d       99.9 //derp
819 da      99.9999,0.0001,-1
820 bar     oorgle
821 bara    blaawp,oorgle,blaawp
822 #commented hi
823 // slashed also commented
824 invalid ho
825 
826 [DifferentSection]
827 ignored completely
828 because no DifferentSection struct was passed
829 nil     5
830 naN     !"¤%&/`;
831 
832     string[][string] missing;
833     string[][string] invalid;
834 
835     FooSettings foo;
836     serialisedFileContents
837         .splitter("\n")
838         .deserialise(missing, invalid, foo);
839 
840     with (foo)
841     {
842         assert((i == 42), i.text);
843         assert((ia == [ 1, 2, -3, 4, 5 ]), ia.text);
844         assert((s == "hello world!"), s);
845         assert((sa == [ "hello", "world", "!" ]), sa.text);
846         assert(b);
847         assert((ba == [ true, false, true ]), ba.text);
848         assert((f == 3.14f), f.text);
849         assert((fa == [ 0.0f, 1.1f, -2.2f, 3.3f ]), fa.text);
850         assert((d == 99.9), d.text);
851 
852         static if (__VERSION__ >= 2091)
853         {
854             import std.math : isClose;
855         }
856         else
857         {
858             import std.math : approxEqual;
859             alias isClose = approxEqual;
860         }
861 
862         // rounding errors with LDC on Windows
863         assert(isClose(da[0], 99.9999), da[0].text);
864         assert(isClose(da[1], 0.0001), da[1].text);
865         assert(isClose(da[2], -1.0), da[2].text);
866 
867         with (FooSettings.Bar)
868         {
869             assert((bar == oorgle), bar.text);
870             assert((bara == [ blaawp, oorgle, blaawp ]), bara.text);
871         }
872     }
873 
874     import std.algorithm.searching : canFind;
875 
876     assert("Foo" in missing);
877     assert(missing["Foo"].canFind("missing"));
878     assert(!missing["Foo"].canFind("commented"));
879     assert(!missing["Foo"].canFind("slashed"));
880     assert("Foo" in invalid);
881     assert(invalid["Foo"].canFind("invalid"));
882 
883     struct DifferentSection
884     {
885         string ignored;
886         string because;
887         int nil;
888         string naN;
889     }
890 
891     // Can read other structs from the same file
892 
893     DifferentSection diff;
894     serialisedFileContents
895         .splitter("\n")
896         .deserialise(missing, invalid, diff);
897 
898     with (diff)
899     {
900         assert((ignored == "completely"), ignored);
901         assert((because == "no DifferentSection struct was passed"), because);
902         assert((nil == 5), nil.text);
903         assert((naN == `!"¤%&/`), naN);
904     }
905 
906     enum Letters { abc, def, ghi, }
907 
908     struct Struct
909     {
910         Letters lt = Letters.def;
911     }
912 
913     enum configContents =
914 `[Struct]
915 lt ghi
916 `;
917     Struct st;
918     configContents
919         .splitter("\n")
920         .deserialise(missing, invalid, st);
921 
922     assert(st.lt == Letters.ghi);
923 
924     class Class
925     {
926         enum Bar { blaawp = 5, oorgle = -1 }
927         int i;
928         string s;
929         bool b;
930         float f;
931         double d;
932         Bar bar;
933         string omitted;
934 
935         @Separator(",")
936         {
937             int[] ia;
938             string[] sa;
939             bool[] ba;
940             float[] fa;
941             double[] da;
942             Bar[] bara;
943         }
944     }
945 
946     enum serialisedFileContentsClass =
947 `[Class]
948 i       42
949 ia      1,2,-3,4,5
950 s       hello world!
951 sa      hello,world,!
952 b       true
953 ba      true,false,true
954 wrong   name
955 
956 # comment
957 ; other type of comment
958 // third type of comment
959 
960 f       3.14 #hirp
961 fa      0.0,1.1,-2.2,3.3 ;herp
962 d       99.9 //derp
963 da      99.9999,0.0001,-1
964 bar     oorgle
965 bara    blaawp,oorgle,blaawp`;
966 
967     Class c = new Class;
968     serialisedFileContentsClass
969         .splitter("\n")
970         .deserialise(missing, invalid, c);
971 
972     with (c)
973     {
974         assert((i == 42), i.text);
975         assert((ia == [ 1, 2, -3, 4, 5 ]), ia.text);
976         assert((s == "hello world!"), s);
977         assert((sa == [ "hello", "world", "!" ]), sa.text);
978         assert(b);
979         assert((ba == [ true, false, true ]), ba.text);
980         assert((f == 3.14f), f.text);
981         assert((fa == [ 0.0f, 1.1f, -2.2f, 3.3f ]), fa.text);
982         assert((d == 99.9), d.text);
983 
984         static if (__VERSION__ >= 2091)
985         {
986             import std.math : isClose;
987         }
988         else
989         {
990             import std.math : approxEqual;
991             alias isClose = approxEqual;
992         }
993 
994         // rounding errors with LDC on Windows
995         assert(isClose(da[0], 99.9999), da[0].text);
996         assert(isClose(da[1], 0.0001), da[1].text);
997         assert(isClose(da[2], -1.0), da[2].text);
998 
999         with (Class.Bar)
1000         {
1001             assert((bar == oorgle), b.text);
1002             assert((bara == [ blaawp, oorgle, blaawp ]), bara.text);
1003         }
1004     }
1005 }
1006 
1007 
1008 // justifiedEntryValueText
1009 /++
1010     Takes an unformatted string of serialised entry-value text and justifies it
1011     into two neat columns.
1012 
1013     It does one pass through it all first to determine the maximum width of the
1014     entry names, then another to format it and eventually return a flat string.
1015 
1016     Example:
1017     ---
1018     struct Foo
1019     {
1020         // ...
1021     }
1022 
1023     struct Bar
1024     {
1025         // ...
1026     }
1027 
1028     Foo foo;
1029     Bar bar;
1030 
1031     Appender!(char[]) sink;
1032 
1033     sink.serialise(foo, bar);
1034     immutable justified = sink.data.justifiedEntryValueText;
1035     ---
1036 
1037     Params:
1038         origLines = Unjustified raw serialised text.
1039 
1040     Returns:
1041         .ini file-like text, justified into two columns.
1042  +/
1043 string justifiedEntryValueText(const string origLines) pure
1044 {
1045     import lu.numeric : getMultipleOf;
1046     import lu.string : stripped;
1047     import std.algorithm.comparison : max;
1048     import std.algorithm.iteration : joiner, splitter;
1049     import std.array : Appender;
1050 
1051     if (!origLines.length) return string.init;
1052 
1053     enum decentReserveOfLines = 256;
1054     enum decentReserveOfChars = 4096;
1055 
1056     Appender!(string[]) unjustified;
1057     unjustified.reserve(decentReserveOfLines);
1058     size_t longestEntryLength;
1059 
1060     foreach (immutable rawline; origLines.splitter("\n"))
1061     {
1062         immutable line = rawline.stripped;
1063 
1064         if (!line.length)
1065         {
1066             unjustified.put("");
1067             continue;
1068         }
1069 
1070         switch (line[0])
1071         {
1072         case '#':
1073         case ';':
1074         case '[':
1075             // comment or section header
1076             unjustified.put(line);
1077             continue;
1078 
1079         case '/':
1080             if ((line.length > 1) && (line[1] == '/'))
1081             {
1082                 // Also a comment
1083                 goto case '#';
1084             }
1085             goto default;
1086 
1087         default:
1088             import std.format : format;
1089 
1090             immutable result = splitEntryValue(line);
1091             longestEntryLength = max(longestEntryLength, result.entry.length);
1092             unjustified.put("%s %s".format(result.entry, result.value));
1093             break;
1094         }
1095     }
1096 
1097     Appender!(char[]) justified;
1098     justified.reserve(decentReserveOfChars);
1099 
1100     assert((longestEntryLength > 0), "No longest entry; is the struct empty?");
1101     assert((unjustified.data.length > 0), "Unjustified data is empty");
1102 
1103     enum minimumWidth = 24;
1104     immutable width = max(minimumWidth, longestEntryLength.getMultipleOf(4, alwaysOneUp: true));
1105 
1106     foreach (immutable i, immutable line; unjustified.data)
1107     {
1108         if (!line.length)
1109         {
1110             // Don't add a linebreak at the top of the file
1111             if (justified.data.length) justified.put("\n");
1112             continue;
1113         }
1114 
1115         if (i > 0) justified.put('\n');
1116 
1117         switch (line[0])
1118         {
1119         case '#':
1120         case ';':
1121         case '[':
1122             justified.put(line);
1123             continue;
1124 
1125         case '/':
1126             if ((line.length > 1) && (line[1] == '/'))
1127             {
1128                 // Also a comment
1129                 goto case '#';
1130             }
1131             goto default;
1132 
1133         default:
1134             import std.format : formattedWrite;
1135             immutable result = splitEntryValue(line);
1136             justified.formattedWrite("%-*s%s", width, result.entry, result.value);
1137             break;
1138         }
1139     }
1140 
1141     return justified.data;
1142 }
1143 
1144 ///
1145 unittest
1146 {
1147     import std.algorithm.iteration : splitter;
1148     import std.array : Appender;
1149     import lu.uda : Separator;
1150 
1151     struct Foo
1152     {
1153         enum Bar { blaawp = 5, oorgle = -1 }
1154         int someInt = 42;
1155         string someString = "hello world!";
1156         bool someBool = true;
1157         float someFloat = 3.14f;
1158         double someDouble = 99.9;
1159         Bar someBars = Bar.oorgle;
1160         string harbl;
1161 
1162         @Separator(",")
1163         {
1164             int[] intArray = [ 1, 2, -3, 4, 5 ];
1165             string[] stringArrayy = [ "hello", "world", "!" ];
1166             bool[] boolArray = [ true, false, true ];
1167             float[] floatArray = [ 0.0, 1.1, -2.2, 3.3 ];
1168             double[] doubleArray = [ 99.9999, 0.0001, -1.0 ];
1169             Bar[] barArray = [ Bar.blaawp, Bar.oorgle, Bar.blaawp ];
1170             string[] yarn;
1171         }
1172     }
1173 
1174     struct DifferentSection
1175     {
1176         string ignored = "completely";
1177         string because = "   no DifferentSection struct was passed";
1178         int nil = 5;
1179         string naN = `!"#¤%&/`;
1180     }
1181 
1182     Appender!(char[]) sink;
1183     sink.reserve(512);
1184     Foo foo;
1185     DifferentSection diff;
1186     enum unjustified =
1187 `[Foo]
1188 someInt 42
1189 someString hello world!
1190 someBool true
1191 someFloat 3.14
1192 someDouble 99.9
1193 someBars oorgle
1194 #harbl
1195 intArray 1,2,-3,4,5
1196 stringArrayy hello,world,!
1197 boolArray true,false,true
1198 floatArray 0,1.1,-2.2,3.3
1199 doubleArray 99.9999,0.0001,-1
1200 barArray blaawp,oorgle,blaawp
1201 #yarn
1202 
1203 [DifferentSection]
1204 ignored completely
1205 because    no DifferentSection struct was passed
1206 nil 5
1207 naN !"#¤%&/`;
1208 
1209     enum justified =
1210 `[Foo]
1211 someInt                 42
1212 someString              hello world!
1213 someBool                true
1214 someFloat               3.14
1215 someDouble              99.9
1216 someBars                oorgle
1217 #harbl
1218 intArray                1,2,-3,4,5
1219 stringArrayy            hello,world,!
1220 boolArray               true,false,true
1221 floatArray              0,1.1,-2.2,3.3
1222 doubleArray             99.9999,0.0001,-1
1223 barArray                blaawp,oorgle,blaawp
1224 #yarn
1225 
1226 [DifferentSection]
1227 ignored                 completely
1228 because                 no DifferentSection struct was passed
1229 nil                     5
1230 naN                     !"#¤%&/`;
1231 
1232     sink.serialise(foo, diff);
1233     assert((sink.data == unjustified), '\n' ~ sink.data);
1234     immutable configText = justifiedEntryValueText(sink.data.idup);
1235 
1236     assert((configText == justified), '\n' ~ configText);
1237 }
1238 
1239 
1240 // DeserialisationException
1241 /++
1242     Exception, to be thrown when the specified serialised text could not be
1243     parsed, for whatever reason.
1244  +/
1245 final class DeserialisationException : Exception
1246 {
1247     /++
1248         Create a new [DeserialisationException].
1249      +/
1250     this(const string message,
1251         const string file = __FILE__,
1252         const size_t line = __LINE__,
1253         Throwable nextInChain = null) pure nothrow @nogc @safe
1254     {
1255         super(message, file, line, nextInChain);
1256     }
1257 }
1258 
1259 
1260 // splitEntryValue
1261 /++
1262     Splits a line into an entry and a value component.
1263 
1264     This drop-in-replaces the regex: `^(?P<entry>[^ \t]+)[ \t]+(?P<value>.+)`.
1265 
1266     Params:
1267         line = String to split up.
1268 
1269     Returns:
1270         A Voldemort struct with an `entry` and a `value` member.
1271  +/
1272 auto splitEntryValue(const string line) pure nothrow @nogc
1273 {
1274     import std.string : representation;
1275     import std.ascii : isWhite;
1276 
1277     struct EntryValue
1278     {
1279         string entry;
1280         string value;
1281     }
1282 
1283     EntryValue result;
1284 
1285     foreach (immutable i, immutable c; line.representation)
1286     {
1287         if (!c.isWhite)
1288         {
1289             if (result.entry.length)
1290             {
1291                 result.value = line[i..$];
1292                 break;
1293             }
1294         }
1295         else if (!result.entry.length)
1296         {
1297             result.entry = line[0..i];
1298         }
1299     }
1300 
1301     if (!result.entry.length) result.entry = line;
1302 
1303     return result;
1304 }
1305 
1306 ///
1307 unittest
1308 {
1309     {
1310         immutable line = "monochrome            true";
1311         immutable result = splitEntryValue(line);
1312         assert((result.entry == "monochrome"), result.entry);
1313         assert((result.value == "true"), result.value);
1314     }
1315     {
1316         immutable line = "monochrome\tfalse";
1317         immutable result = splitEntryValue(line);
1318         assert((result.entry == "monochrome"), result.entry);
1319         assert((result.value == "false"), result.value);
1320     }
1321     {
1322         immutable line = "harbl                  ";
1323         immutable result = splitEntryValue(line);
1324         assert((result.entry == "harbl"), result.entry);
1325         assert(!result.value.length, result.value);
1326     }
1327     {
1328         immutable line = "ha\t \t \t\t  \t  \t      \tha";
1329         immutable result = splitEntryValue(line);
1330         assert((result.entry == "ha"), result.entry);
1331         assert((result.value == "ha"), result.value);
1332     }
1333     {
1334         immutable line = "#sendAfterConnect";
1335         immutable result = splitEntryValue(line);
1336         assert((result.entry == "#sendAfterConnect"), result.entry);
1337         assert(!result.value.length, result.value);
1338     }
1339 }