1 /**
2  * Reading and writing ini-like files, used in Unix systems.
3  * Authors: 
4  *  $(LINK2 https://github.com/MyLittleRobo, Roman Chistokhodov).
5  * Copyright:
6  *  Roman Chistokhodov, 2015
7  * License: 
8  *  $(LINK2 http://www.boost.org/LICENSE_1_0.txt, Boost License 1.0).
9  * See_Also: 
10  *  $(LINK2 http://standards.freedesktop.org/desktop-entry-spec/latest/index.html, Desktop Entry Specification).
11  */
12 
13 module inilike;
14 
15 private {
16     import std.algorithm;
17     import std.array;
18     import std.conv;
19     import std.exception;
20     import std.file;
21     import std.path;
22     import std.process;
23     import std.range;
24     import std.stdio;
25     import std.string;
26     import std.traits;
27     import std.typecons;
28     
29     static if( __VERSION__ < 2066 ) enum nogc = 1;
30 }
31 
32 private alias LocaleTuple = Tuple!(string, "lang", string, "country", string, "encoding", string, "modifier");
33 private alias KeyValueTuple = Tuple!(string, "key", string, "value");
34 
35 /** Retrieve current locale probing environment variables LC_TYPE, LC_ALL and LANG (in this order)
36  * Returns: locale in posix form or an empty string if could not determine locale.
37  * Note: this function does not cache its results.
38  */
39 @safe string currentLocale() nothrow
40 {
41     string cache;
42     try {
43         cache = environment.get("LC_CTYPE", environment.get("LC_ALL", environment.get("LANG")));
44     }
45     catch(Exception e) {
46         
47     }
48     return cache;
49 }
50 
51 ///
52 unittest 
53 {
54     if (environment.get("CI") != "true") { //for some reason it can't set environment variables in Travis CI
55         environment["LANG"] = "ru_RU";
56         assert(currentLocale() == "ru_RU");
57         environment["LC_ALL"] = "de_DE";
58         assert(currentLocale() == "de_DE");
59         environment["LC_CTYPE"] = "fr_BE";
60         assert(currentLocale() == "fr_BE");
61     }
62 }
63 
64 /**
65  * Make locale name based on language, country, encoding and modifier.
66  * Returns: locale name in form lang_COUNTRY.ENCODING@MODIFIER
67  * See_Also: parseLocaleName
68  */
69 @safe string makeLocaleName(string lang, string country = null, string encoding = null, string modifier = null) pure nothrow
70 {
71     return lang ~ (country.length ? "_"~country : "") ~ (encoding.length ? "."~encoding : "") ~ (modifier.length ? "@"~modifier : "");
72 }
73 
74 ///
75 unittest
76 {
77     assert(makeLocaleName("ru", "RU") == "ru_RU");
78     assert(makeLocaleName("ru", "RU", "UTF-8") == "ru_RU.UTF-8");
79     assert(makeLocaleName("ru", "RU", "UTF-8", "mod") == "ru_RU.UTF-8@mod");
80     assert(makeLocaleName("ru", null, null, "mod") == "ru@mod");
81 }
82 
83 /**
84  * Parse locale name into the tuple of 4 values corresponding to language, country, encoding and modifier
85  * Returns: Tuple!(string, "lang", string, "country", string, "encoding", string, "modifier")
86  * See_Also: makeLocaleName
87  */
88 @nogc @trusted auto parseLocaleName(string locale) pure nothrow
89 {
90     auto modifiderSplit = findSplit(locale, "@");
91     auto modifier = modifiderSplit[2];
92     
93     auto encodongSplit = findSplit(modifiderSplit[0], ".");
94     auto encoding = encodongSplit[2];
95     
96     auto countrySplit = findSplit(encodongSplit[0], "_");
97     auto country = countrySplit[2];
98     
99     auto lang = countrySplit[0];
100     
101     return LocaleTuple(lang, country, encoding, modifier);
102 }
103 
104 ///
105 unittest 
106 {
107     assert(parseLocaleName("ru_RU.UTF-8@mod") == tuple("ru", "RU", "UTF-8", "mod"));
108     assert(parseLocaleName("ru@mod") == tuple("ru", string.init, string.init, "mod"));
109     assert(parseLocaleName("ru_RU") == tuple("ru", "RU", string.init, string.init));
110 }
111 
112 /**
113  * Construct localized key name from key and locale.
114  * Returns: localized key in form key[locale]. Automatically omits locale encoding if present.
115  * See_Also: separateFromLocale
116  */
117 @safe string localizedKey(string key, string locale) pure nothrow
118 {
119     auto t = parseLocaleName(locale);
120     if (!t.encoding.empty) {
121         locale = makeLocaleName(t.lang, t.country, null, t.modifier);
122     }
123     return key ~ "[" ~ locale ~ "]";
124 }
125 
126 ///
127 unittest 
128 {
129     assert(localizedKey("Name", "ru_RU") == "Name[ru_RU]");
130     assert(localizedKey("Name", "ru_RU.UTF-8") == "Name[ru_RU]");
131 }
132 
133 /**
134  * ditto, but constructs locale name from arguments.
135  */
136 @safe string localizedKey(string key, string lang, string country, string modifier = null) pure nothrow
137 {
138     return key ~ "[" ~ makeLocaleName(lang, country, null, modifier) ~ "]";
139 }
140 
141 ///
142 unittest 
143 {
144     assert(localizedKey("Name", "ru", "RU") == "Name[ru_RU]");
145 }
146 
147 /** 
148  * Separate key name into non-localized key and locale name.
149  * If key is not localized returns original key and empty string.
150  * Returns: tuple of key and locale name.
151  * See_Also: localizedKey
152  */
153 @nogc @trusted Tuple!(string, string) separateFromLocale(string key) pure nothrow {
154     if (key.endsWith("]")) {
155         auto t = key.findSplit("[");
156         if (t[1].length) {
157             return tuple(t[0], t[2][0..$-1]);
158         }
159     }
160     return tuple(key, string.init);
161 }
162 
163 ///
164 unittest 
165 {
166     assert(separateFromLocale("Name[ru_RU]") == tuple("Name", "ru_RU"));
167     assert(separateFromLocale("Name") == tuple("Name", string.init));
168 }
169 
170 /**
171  * Tells whether the entry value presents true
172  */
173 @nogc @safe bool isTrue(string value) pure nothrow {
174     return (value == "true" || value == "1");
175 }
176 
177 ///
178 unittest 
179 {
180     assert(isTrue("true"));
181     assert(isTrue("1"));
182     assert(!isTrue("not boolean"));
183 }
184 
185 /**
186  * Tells whether the entry value presents false
187  */
188 @nogc @safe bool isFalse(string value) pure nothrow {
189     return (value == "false" || value == "0");
190 }
191 
192 ///
193 unittest 
194 {
195     assert(isFalse("false"));
196     assert(isFalse("0"));
197     assert(!isFalse("not boolean"));
198 }
199 
200 /**
201  * Check if the entry value can be interpreted as boolean value.
202  * See_Also: isTrue, isFalse
203  */
204 @nogc @safe bool isBoolean(string value) pure nothrow {
205     return isTrue(value) || isFalse(value);
206 }
207 
208 ///
209 unittest 
210 {
211     assert(isBoolean("true"));
212     assert(isBoolean("1"));
213     assert(isBoolean("false"));
214     assert(isBoolean("0"));
215     assert(!isBoolean("not boolean"));
216 }
217 
218 /**
219  * Escapes string by replacing special symbols with escaped sequences. 
220  * These symbols are: '\\' (backslash), '\n' (newline), '\r' (carriage return) and '\t' (tab).
221  * Note: 
222  *  Currently the library stores values as they were loaded from file, i.e. escaped. 
223  *  To keep things consistent you should take care about escaping the value before inserting. The library will not do it for you.
224  * Returns: Escaped string.
225  * See_Also: unescapeValue
226  */
227 @trusted string escapeValue(string value) nothrow pure {
228     return value.replace("\\", `\\`).replace("\n", `\n`).replace("\r", `\r`).replace("\t", `\t`);
229 }
230 
231 ///
232 unittest 
233 {
234     assert("a\\next\nline\top".escapeValue() == `a\\next\nline\top`); // notice how the string on the right is raw.
235 }
236 
237 @trusted string doUnescape(string value, in Tuple!(char, char)[] pairs) nothrow pure {
238     auto toReturn = appender!string();
239     
240     for (size_t i = 0; i < value.length; i++) {
241         if (value[i] == '\\') {
242             if (i < value.length - 1) {
243                 char c = value[i+1];
244                 auto t = pairs.find!"a[0] == b[0]"(tuple(c,c));
245                 if (!t.empty) {
246                     toReturn.put(t.front[1]);
247                     i++;
248                     continue;
249                 }
250             }
251         }
252         toReturn.put(value[i]);
253     }
254     return toReturn.data;
255 }
256 
257 
258 /**
259  * Unescapes string. You should unescape values returned by library before displaying until you want keep them as is (e.g., to allow user to edit values in escaped form).
260  * Returns: Unescaped string.
261  * See_Also: escapeValue
262  */
263 @trusted string unescapeValue(string value) nothrow pure
264 {
265     static immutable Tuple!(char, char)[] pairs = [
266        tuple('s', ' '),
267        tuple('n', '\n'),
268        tuple('r', '\r'),
269        tuple('t', '\t'),
270        tuple('\\', '\\')
271     ];
272     return doUnescape(value, pairs);
273 }
274 
275 ///
276 unittest 
277 {
278     assert(`a\\next\nline\top`.unescapeValue() == "a\\next\nline\top"); // notice how the string on the left is raw.
279 }
280 
281 /**
282  * Represents the line from ini-like file.
283  * Usually you should not use this struct directly, since it's tightly connected with internal $(B IniLikeFile) implementation.
284  */
285 struct IniLikeLine
286 {
287     enum Type
288     {
289         None = 0,
290         Comment = 1,
291         KeyValue = 2,
292         GroupStart = 4
293     }
294     
295     @nogc @safe static IniLikeLine fromComment(string comment) nothrow {
296         return IniLikeLine(comment, null, Type.Comment);
297     }
298     
299     @nogc @safe static IniLikeLine fromGroupName(string groupName) nothrow {
300         return IniLikeLine(groupName, null, Type.GroupStart);
301     }
302     
303     @nogc @safe static IniLikeLine fromKeyValue(string key, string value) nothrow {
304         return IniLikeLine(key, value, Type.KeyValue);
305     }
306     
307     @nogc @safe string comment() const nothrow {
308         return _type == Type.Comment ? _first : null;
309     }
310     
311     @nogc @safe string key() const nothrow {
312         return _type == Type.KeyValue ? _first : null;
313     }
314     
315     @nogc @safe string value() const nothrow {
316         return _type == Type.KeyValue ? _second : null;
317     }
318     
319     @nogc @safe string groupName() const nothrow {
320         return _type == Type.GroupStart ? _first : null;
321     }
322     
323     @nogc @safe Type type() const nothrow {
324         return _type;
325     }
326     
327     @nogc @safe void makeNone() nothrow {
328         _type = Type.None;
329     }
330     
331 private:
332     string _first;
333     string _second;
334     Type _type = Type.None;
335 }
336 
337 /**
338  * This class represents the group (section) in the .init like file. 
339  * You can create and use instances of this class only in the context of $(B IniLikeFile) or its derivatives.
340  * Note: keys are case-sensitive.
341  */
342 final class IniLikeGroup
343 {
344 private:
345     @nogc @safe this(string name, const IniLikeFile parent) nothrow {
346         assert(parent, "logic error: no parent for IniLikeGroup");
347         _name = name;
348         _parent = parent;
349     }
350     
351 public:
352     
353     /**
354      * Returns: the value associated with the key
355      * Note: it's an error to access nonexistent value
356      * See_Also: value
357      */
358     @nogc @safe string opIndex(string key) const nothrow {
359         auto i = key in _indices;
360         assert(_values[*i].type == IniLikeLine.Type.KeyValue);
361         assert(_values[*i].key == key);
362         return _values[*i].value;
363     }
364     
365     /**
366      * Insert new value or replaces the old one if value associated with key already exists.
367      * Returns: inserted/updated value
368      * Throws: $(B Exception) if key is not valid
369      */
370     @safe string opIndexAssign(string value, string key) {
371         enforce(_parent.isValidKey(separateFromLocale(key)[0]), "key is invalid");
372         auto pick = key in _indices;
373         if (pick) {
374             return (_values[*pick] = IniLikeLine.fromKeyValue(key, value)).value;
375         } else {
376             _indices[key] = _values.length;
377             _values ~= IniLikeLine.fromKeyValue(key, value);
378             return value;
379         }
380     }
381     /**
382      * Ditto, localized version.
383      * See_Also: setLocalizedValue, localizedValue
384      */
385     @safe string opIndexAssign(string value, string key, string locale) {
386         string keyName = localizedKey(key, locale);
387         return this[keyName] = value;
388     }
389     
390     /**
391      * Tell if group contains value associated with the key.
392      */
393     @nogc @safe bool contains(string key) const nothrow {
394         return value(key) !is null;
395     }
396     
397     /**
398      * Get value by key.
399      * Returns: the value associated with the key, or defaultValue if group does not contain item with this key.
400      */
401     @nogc @safe string value(string key, string defaultValue = null) const nothrow {
402         auto pick = key in _indices;
403         if (pick) {
404             if(_values[*pick].type == IniLikeLine.Type.KeyValue) {
405                 assert(_values[*pick].key == key);
406                 return _values[*pick].value;
407             }
408         }
409         return defaultValue;
410     }
411     
412     /**
413      * Perform locale matching lookup as described in $(LINK2 http://standards.freedesktop.org/desktop-entry-spec/latest/ar01s04.html, Localized values for keys).
414      * Returns: the localized value associated with key and locale, or defaultValue if group does not contain item with this key.
415      * See_Also: currentLocale
416      */
417     @safe string localizedValue(string key, string locale, string defaultValue = null) const nothrow {
418         //Any ideas how to get rid of this boilerplate and make less allocations?
419         auto t = parseLocaleName(locale);
420         auto lang = t.lang;
421         auto country = t.country;
422         auto modifier = t.modifier;
423         
424         if (lang.length) {
425             string pick;
426             if (country.length && modifier.length) {
427                 pick = value(localizedKey(key, locale));
428                 if (pick !is null) {
429                     return pick;
430                 }
431             }
432             if (country.length) {
433                 pick = value(localizedKey(key, lang, country));
434                 if (pick !is null) {
435                     return pick;
436                 }
437             }
438             if (modifier.length) {
439                 pick = value(localizedKey(key, lang, null, modifier));
440                 if (pick !is null) {
441                     return pick;
442                 }
443             }
444             pick = value(localizedKey(key, lang, null));
445             if (pick !is null) {
446                 return pick;
447             }
448         }
449         
450         return value(key, defaultValue);
451     }
452     
453     ///
454     unittest 
455     {
456         auto lilf = new IniLikeFile;
457         lilf.addGroup("Entry");
458         auto group = lilf.group("Entry");
459         assert(group.name == "Entry"); 
460         group["Name"] = "Programmer";
461         group["Name[ru_RU]"] = "Разработчик";
462         group["Name[ru@jargon]"] = "Кодер";
463         group["Name[ru]"] = "Программист";
464         group["Name[de_DE@dialect]"] = "Programmierer"; //just example
465         group["GenericName"] = "Program";
466         group["GenericName[ru]"] = "Программа";
467         assert(group["Name"] == "Programmer");
468         assert(group.localizedValue("Name", "ru@jargon") == "Кодер");
469         assert(group.localizedValue("Name", "ru_RU@jargon") == "Разработчик");
470         assert(group.localizedValue("Name", "ru") == "Программист");
471         assert(group.localizedValue("Name", "nonexistent locale") == "Programmer");
472         assert(group.localizedValue("Name", "de_DE@dialect") == "Programmierer");
473         assert(group.localizedValue("GenericName", "ru_RU") == "Программа");
474     }
475     
476     /**
477      * Same as localized version of opIndexAssign, but uses function syntax.
478      */
479     @safe void setLocalizedValue(string key, string locale, string value) {
480         this[key, locale] = value;
481     }
482     
483     /**
484      * Removes entry by key. To remove localized values use localizedKey.
485      */
486     @safe void removeEntry(string key) nothrow {
487         auto pick = key in _indices;
488         if (pick) {
489             _values[*pick].makeNone();
490         }
491     }
492     
493     /**
494      * Iterate by Key-Value pairs.
495      * Returns: range of Tuple!(string, "key", string, "value")
496      */
497     @nogc @safe auto byKeyValue() const nothrow {
498         return _values.filter!(v => v.type == IniLikeLine.Type.KeyValue).map!(v => KeyValueTuple(v.key, v.value));
499     }
500     
501     /**
502      * Get name of this group.
503      * Returns: the name of this group.
504      */
505     @nogc @safe string name() const nothrow {
506         return _name;
507     }
508     
509     /**
510      * Returns: the range of $(B IniLikeLine)s included in this group.
511      * Note: this does not include Group line itself.
512      */
513     @system auto byIniLine() const {
514         return _values.filter!(v => v.type != IniLikeLine.Type.None);
515     }
516     
517     @trusted void addComment(string comment) nothrow {
518         _values ~= IniLikeLine.fromComment(comment);
519     }
520     
521 private:
522     size_t[string] _indices;
523     IniLikeLine[] _values;
524     string _name;
525     const IniLikeFile _parent;
526 }
527 
528 /**
529  * Exception thrown on the file read error.
530  */
531 class IniLikeException : Exception
532 {
533     this(string msg, size_t lineNumber, string file = __FILE__, size_t line = __LINE__, Throwable next = null) pure nothrow @safe {
534         super(msg, file, line, next);
535         _lineNumber = lineNumber;
536     }
537     
538     ///Number of line in the file where the exception occured, starting from 1. Don't be confused with $(B line) property of $(B Throwable).
539     @nogc @safe size_t lineNumber() const nothrow {
540         return _lineNumber;
541     }
542     
543 private:
544     size_t _lineNumber;
545 }
546 
547 /**
548  * Reads range of strings into the range of IniLikeLines.
549  * See_Also: iniLikeFileReader, iniLikeStringReader
550  */
551 @trusted auto iniLikeRangeReader(Range)(Range byLine) if(is(ElementType!Range : string))
552 {
553     return byLine.map!(function(string line) {
554         line = strip(line);
555         if (line.empty || line.startsWith("#")) {
556             return IniLikeLine.fromComment(line);
557         } else if (line.startsWith("[") && line.endsWith("]")) {
558             return IniLikeLine.fromGroupName(line[1..$-1]);
559         } else {
560             auto t = line.findSplit("=");
561             auto key = t[0].stripRight();
562             auto value = t[2].stripLeft();
563             
564             if (t[1].length) {
565                 return IniLikeLine.fromKeyValue(key, value);
566             } else {
567                 return IniLikeLine();
568             }         
569         }
570     });
571 }
572 
573 /**
574  * ditto, convenient function for reading from the file.
575  * Throws: $(B ErrnoException) if file could not be opened.
576  */
577 @trusted auto iniLikeFileReader(string fileName)
578 {
579     static if( __VERSION__ < 2067 ) {
580         return iniLikeRangeReader(File(fileName, "r").byLine().map!(s => s.idup));
581     } else {
582         return iniLikeRangeReader(File(fileName, "r").byLineCopy());
583     }
584 }
585 
586 /**
587  * ditto, convenient function for reading from string.
588  */
589 @trusted auto iniLikeStringReader(string contents)
590 {
591     static if( __VERSION__ < 2067 ) {
592         return iniLikeRangeReader(contents.splitLines());
593     } else {
594         return iniLikeRangeReader(contents.lineSplitter());
595     }
596 }
597 
598 /**
599  * Ini-like file.
600  * 
601  */
602 class IniLikeFile
603 {
604 public:
605     ///Flags to manage .ini like file reading
606     enum ReadOptions
607     {
608         noOptions = 0,              /// Read all groups and skip comments and empty lines.
609         firstGroupOnly = 1,         /// Ignore other groups than the first one.
610         preserveComments = 2,       /// Preserve comments and empty lines. Use this when you want to keep them across writing.
611         ignoreGroupDuplicates = 4,  /// Ignore group duplicates. The first found will be used.
612         ignoreInvalidKeys = 8       /// Skip invalid keys during parsing.
613     }
614     
615     /**
616      * Construct empty IniLikeFile, i.e. without any groups or values
617      */
618     @nogc @safe this() nothrow {
619         
620     }
621     
622     /**
623      * Read from file.
624      * Throws:
625      *  $(B ErrnoException) if file could not be opened.
626      *  $(B IniLikeException) if error occured while reading the file.
627      */
628     @safe this(string fileName, ReadOptions options = ReadOptions.noOptions) {
629         this(iniLikeFileReader(fileName), options, fileName);
630     }
631     
632     /**
633      * Read from range of $(B IniLikeLine)s.
634      * Throws:
635      *  $(B IniLikeException) if error occured while parsing.
636      */
637     @trusted this(Range)(Range byLine, ReadOptions options = ReadOptions.noOptions, string fileName = null) if(is(ElementType!Range : IniLikeLine))
638     {
639         size_t lineNumber = 0;
640         IniLikeGroup currentGroup;
641         bool ignoreKeyValues;
642         
643         try {
644             foreach(line; byLine)
645             {
646                 lineNumber++;
647                 final switch(line.type)
648                 {
649                     case IniLikeLine.Type.Comment:
650                     {
651                         if (options & ReadOptions.preserveComments) {
652                             if (currentGroup is null) {
653                                 addFirstComment(line.comment);
654                             } else {
655                                 currentGroup.addComment(line.comment);
656                             }
657                         }
658                     }
659                     break;
660                     case IniLikeLine.Type.GroupStart:
661                     {
662                         if (currentGroup !is null && (options & ReadOptions.firstGroupOnly)) {
663                             break;
664                         }
665                         
666                         if ((options & ReadOptions.ignoreGroupDuplicates) && group(line.groupName)) {
667                             ignoreKeyValues = true;
668                             continue;
669                         }
670                         ignoreKeyValues = false;
671                         currentGroup = addGroup(line.groupName);
672                     }
673                     break;
674                     case IniLikeLine.Type.KeyValue:
675                     {
676                         if (ignoreKeyValues || ((options & ReadOptions.ignoreInvalidKeys) && !isValidKey(line.key)) ) {
677                             continue;
678                         }
679                         enforce(currentGroup, "met key-value pair before any group");
680                         currentGroup[line.key] = line.value;
681                     }
682                     break;
683                     case IniLikeLine.Type.None:
684                     {
685                         throw new Exception("not key-value pair, nor group start nor comment");
686                     }
687                 }
688             }
689             
690             _fileName = fileName;
691         }
692         catch (Exception e) {
693             throw new IniLikeException(e.msg, lineNumber, e.file, e.line, e.next);
694         }
695     }
696     
697     /**
698      * Get group by name.
699      * Returns: IniLikeGroup instance associated with groupName or $(B null) if not found.
700      * See_Also: byGroup
701      */
702     @nogc @safe inout(IniLikeGroup) group(string groupName) nothrow inout {
703         auto pick = groupName in _groupIndices;
704         if (pick) {
705             return _groups[*pick];
706         }
707         return null;
708     }
709     
710     /**
711      * Create new group using groupName.
712      * Returns: newly created instance of IniLikeGroup.
713      * Throws: Exception if group with such name already exists or groupName is empty.
714      * See_Also: removeGroup, group
715      */
716     @safe IniLikeGroup addGroup(string groupName) {
717         enforce(groupName.length, "empty group name");
718         enforce(group(groupName) is null, "group already exists");
719         
720         auto iniLikeGroup = new IniLikeGroup(groupName, this);
721         _groupIndices[groupName] = _groups.length;
722         _groups ~= iniLikeGroup;
723         
724         return iniLikeGroup;
725     }
726     
727     /**
728      * Remove group by name.
729      * See_Also: addGroup, group
730      */
731     @safe void removeGroup(string groupName) nothrow {
732         auto pick = groupName in _groupIndices;
733         if (pick) {
734             _groups[*pick] = null;
735         }
736     }
737     
738     /**
739      * Range of groups in order how they were defined in file.
740      * See_Also: group
741      */
742     @nogc @safe final auto byGroup() const nothrow {
743         return _groups.filter!(f => f !is null);
744     }
745     
746     ///ditto
747     @nogc @safe final auto byGroup() nothrow {
748         return _groups.filter!(f => f !is null);
749     }
750     
751     
752     /**
753      * Save object to the file using .ini-like format.
754      * Throws: ErrnoException if the file could not be opened or an error writing to the file occured.
755      * See_Also: saveToString, save
756      */
757     @trusted void saveToFile(string fileName) const {
758         auto f = File(fileName, "w");
759         void dg(string line) {
760             f.writeln(line);
761         }
762         save(&dg);
763     }
764     
765     /**
766      * Save object to string using .ini like format.
767      * Returns: string representing the contents of file.
768      * See_Also: saveToFile, save
769      */
770     @safe string saveToString() const {
771         auto a = appender!(string[])();
772         void dg(string line) {
773             a.put(line);
774         }
775         save(&dg);
776         return a.data.join("\n");
777     }
778     
779     /**
780      * Alias for saving delegate.
781      * See_Also: save
782      */
783     alias SaveDelegate = void delegate(string);
784     
785     /**
786      * Use delegate to retrieve strings line by line. 
787      * Those strings can be written to the file or be showed in text area.
788      * Note: returned strings don't have trailing newline character.
789      */
790     @trusted void save(SaveDelegate sink) const {
791         foreach(line; firstComments()) {
792             sink(line);
793         }
794         
795         foreach(group; byGroup()) {
796             sink("[" ~ group.name ~ "]");
797             foreach(line; group.byIniLine()) {
798                 if (line.type == IniLikeLine.Type.Comment) {
799                     sink(line.comment);
800                 } else if (line.type == IniLikeLine.Type.KeyValue) {
801                     sink(line.key ~ "=" ~ line.value);
802                 }
803             }
804         }
805     }
806     
807     /**
808      * File path where the object was loaded from.
809      * Returns: file name as was specified on the object creation.
810      */
811     @nogc @safe string fileName() nothrow const {
812         return  _fileName;
813     }
814     
815     /**
816     * Tell whether the string is valid key. For IniLikeFile the valid key is any non-empty string.
817     * Reimplement this function in the derived class to throw exception from IniLikeGroup when key is invalid.
818     */
819     @nogc @safe bool isValidKey(string key) pure nothrow const {
820         return key.length != 0;
821     }
822     
823 protected:
824     @nogc @trusted final auto firstComments() const nothrow {
825         return _firstComments;
826     }
827     
828     @trusted final void addFirstComment(string line) nothrow {
829         _firstComments ~= line;
830     }
831     
832 private:
833     string _fileName;
834     size_t[string] _groupIndices;
835     IniLikeGroup[] _groups;
836     string[] _firstComments;
837 }
838 
839 ///
840 unittest
841 {
842     string contents = 
843 `# The first comment
844 [First Entry]
845 # Comment
846 GenericName=File manager
847 GenericName[ru]=Файловый менеджер
848 # Another comment
849 [Another Group]
850 Name=Commander
851 Comment=Manage files
852 # The last comment`;
853 
854     auto ilf = new IniLikeFile(iniLikeStringReader(contents), IniLikeFile.ReadOptions.preserveComments, "contents.ini");
855     assert(ilf.fileName() == "contents.ini");
856     assert(ilf.group("First Entry"));
857     assert(ilf.group("Another Group"));
858     assert(ilf.saveToString() == contents);
859     
860     string tempFile = buildPath(tempDir(), "inilike-unittest-tempfile");
861     try {
862         assertNotThrown!IniLikeException(ilf.saveToFile(tempFile));
863         auto fileContents = cast(string)std.file.read(tempFile);
864         static if( __VERSION__ < 2067 ) {
865             assert(equal(fileContents.splitLines, contents.splitLines), "Contents should be preserved as is");
866         } else {
867             assert(equal(fileContents.lineSplitter, contents.lineSplitter), "Contents should be preserved as is");
868         }
869         
870         IniLikeFile filf; 
871         assertNotThrown!IniLikeException(filf = new IniLikeFile(tempFile, IniLikeFile.ReadOptions.preserveComments));
872         assert(filf.fileName() == tempFile);
873         remove(tempFile);
874     } catch(Exception e) {
875         //probably some environment issue unrelated to unittest itself, e.g. could not write to file.
876     }
877     
878     auto firstEntry = ilf.group("First Entry");
879     
880     assert(firstEntry["GenericName"] == "File manager");
881     assert(firstEntry.value("GenericName") == "File manager");
882     firstEntry["GenericName"] = "Manager of files";
883     assert(firstEntry["GenericName"] == "Manager of files");
884     firstEntry["Authors"] = "Unknown";
885     assert(firstEntry["Authors"] == "Unknown");
886     
887     assert(firstEntry.localizedValue("GenericName", "ru") == "Файловый менеджер");
888     firstEntry.setLocalizedValue("GenericName", "ru", "Менеджер файлов");
889     assert(firstEntry.localizedValue("GenericName", "ru") == "Менеджер файлов");
890     firstEntry.setLocalizedValue("Authors", "ru", "Неизвестны");
891     assert(firstEntry.localizedValue("Authors", "ru") == "Неизвестны");
892     
893     firstEntry.removeEntry("GenericName");
894     assert(!firstEntry.contains("GenericName"));
895     firstEntry["GenericName"] = "File Manager";
896     assert(firstEntry["GenericName"] == "File Manager");
897     
898     assert(ilf.group("Another Group")["Name"] == "Commander");
899     assert(equal(ilf.group("Another Group").byKeyValue(), [ KeyValueTuple("Name", "Commander"), KeyValueTuple("Comment", "Manage files") ]));
900     
901     assert(equal(ilf.byGroup().map!(g => g.name), ["First Entry", "Another Group"]));
902     
903     ilf.removeGroup("Another Group");
904     assert(!ilf.group("Another Group"));
905     assert(equal(ilf.byGroup().map!(g => g.name), ["First Entry"]));
906     
907     ilf.addGroup("Another Group");
908     assert(ilf.group("Another Group"));
909     assert(ilf.group("Another Group").byIniLine().empty);
910     assert(ilf.group("Another Group").byKeyValue().empty);
911     
912     ilf.addGroup("Other Group");
913     assert(equal(ilf.byGroup().map!(g => g.name), ["First Entry", "Another Group", "Other Group"]));
914     
915     const IniLikeFile cilf = ilf;
916     static assert(is(typeof(cilf.byGroup())));
917     static assert(is(typeof(cilf.group("First Entry").byKeyValue())));
918     static assert(is(typeof(cilf.group("First Entry").byIniLine())));
919     
920     contents = 
921 `[First]
922 Key=Value
923 [Second]
924 Key=Value`;
925     ilf = new IniLikeFile(iniLikeStringReader(contents), IniLikeFile.ReadOptions.firstGroupOnly);
926     assert(ilf.group("First") !is null);
927     assert(ilf.group("Second") is null);
928     assert(ilf.group("First")["Key"] == "Value");
929     
930     contents = 
931 `[Group]
932 GenericName=File manager
933 [Group]
934 Name=Commander`;
935 
936     IniLikeException shouldThrow = null;
937     try {
938         new IniLikeFile(iniLikeStringReader(contents));
939     } catch(IniLikeException e) {
940         shouldThrow = e;
941     }
942     assert(shouldThrow !is null, "Duplicate groups should throw");
943     assert(shouldThrow.lineNumber == 3);
944     assertNotThrown(new IniLikeFile(iniLikeStringReader(contents), IniLikeFile.ReadOptions.ignoreGroupDuplicates));
945     
946     contents = 
947 `[Group]
948 =File manager`;
949 
950     try {
951         new IniLikeFile(iniLikeStringReader(contents));
952     } catch(IniLikeException e) {
953         shouldThrow = e;
954     }
955     assert(shouldThrow !is null, "Invalid key should throw");
956     assert(shouldThrow.lineNumber == 2);
957     assertNotThrown(new IniLikeFile(iniLikeStringReader(contents), IniLikeFile.ReadOptions.ignoreInvalidKeys));
958     
959     contents = 
960 `[Group]
961 #Comment
962 Valid=Key
963 NotKeyNotGroupNotComment`;
964 
965     try {
966         new IniLikeFile(iniLikeStringReader(contents));
967     } catch(IniLikeException e) {
968         shouldThrow = e;
969     }
970     assert(shouldThrow !is null, "Invalid entry should throw");
971     assert(shouldThrow.lineNumber == 4);
972 }