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