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     return iniLikeRangeReader(contents.splitLines());
592 }
593 
594 /**
595  * Ini-like file.
596  * 
597  */
598 class IniLikeFile
599 {
600 public:
601     ///Flags to manage .ini like file reading
602     enum ReadOptions
603     {
604         noOptions = 0,              /// Read all groups and skip comments and empty lines.
605         firstGroupOnly = 1,         /// Ignore other groups than the first one.
606         preserveComments = 2,       /// Preserve comments and empty lines. Use this when you want to keep them across writing.
607         ignoreGroupDuplicates = 4,  /// Ignore group duplicates. The first found will be used.
608         ignoreInvalidKeys = 8       /// Skip invalid keys during parsing.
609     }
610     
611     /**
612      * Construct empty IniLikeFile, i.e. without any groups or values
613      */
614     @nogc @safe this() nothrow {
615         
616     }
617     
618     /**
619      * Read from file.
620      * Throws:
621      *  $(B ErrnoException) if file could not be opened.
622      *  $(B IniLikeException) if error occured while reading the file.
623      */
624     @safe this(string fileName, ReadOptions options = ReadOptions.noOptions) {
625         this(iniLikeFileReader(fileName), options, fileName);
626     }
627     
628     /**
629      * Read from range of $(B IniLikeLine)s.
630      * Throws:
631      *  $(B IniLikeException) if error occured while parsing.
632      */
633     @trusted this(Range)(Range byLine, ReadOptions options = ReadOptions.noOptions, string fileName = null) if(is(ElementType!Range : IniLikeLine))
634     {
635         size_t lineNumber = 0;
636         IniLikeGroup currentGroup;
637         bool ignoreKeyValues;
638         
639         try {
640             foreach(line; byLine)
641             {
642                 lineNumber++;
643                 final switch(line.type)
644                 {
645                     case IniLikeLine.Type.Comment:
646                     {
647                         if (options & ReadOptions.preserveComments) {
648                             if (currentGroup is null) {
649                                 addFirstComment(line.comment);
650                             } else {
651                                 currentGroup.addComment(line.comment);
652                             }
653                         }
654                     }
655                     break;
656                     case IniLikeLine.Type.GroupStart:
657                     {
658                         if (currentGroup !is null && (options & ReadOptions.firstGroupOnly)) {
659                             break;
660                         }
661                         
662                         if ((options & ReadOptions.ignoreGroupDuplicates) && group(line.groupName)) {
663                             ignoreKeyValues = true;
664                             continue;
665                         }
666                         ignoreKeyValues = false;
667                         currentGroup = addGroup(line.groupName);
668                     }
669                     break;
670                     case IniLikeLine.Type.KeyValue:
671                     {
672                         if (ignoreKeyValues || ((options & ReadOptions.ignoreInvalidKeys) && !isValidKey(line.key)) ) {
673                             continue;
674                         }
675                         enforce(currentGroup, "met key-value pair before any group");
676                         currentGroup[line.key] = line.value;
677                     }
678                     break;
679                     case IniLikeLine.Type.None:
680                     {
681                         throw new Exception("not key-value pair, nor group start nor comment");
682                     }
683                 }
684             }
685             
686             _fileName = fileName;
687         }
688         catch (Exception e) {
689             throw new IniLikeException(e.msg, lineNumber, e.file, e.line, e.next);
690         }
691     }
692     
693     /**
694      * Get group by name.
695      * Returns: IniLikeGroup instance associated with groupName or $(B null) if not found.
696      * See_Also: byGroup
697      */
698     @nogc @safe inout(IniLikeGroup) group(string groupName) nothrow inout {
699         auto pick = groupName in _groupIndices;
700         if (pick) {
701             return _groups[*pick];
702         }
703         return null;
704     }
705     
706     /**
707      * Create new group using groupName.
708      * Returns: newly created instance of IniLikeGroup.
709      * Throws: Exception if group with such name already exists or groupName is empty.
710      * See_Also: removeGroup, group
711      */
712     @safe IniLikeGroup addGroup(string groupName) {
713         enforce(groupName.length, "empty group name");
714         enforce(group(groupName) is null, "group already exists");
715         
716         auto iniLikeGroup = new IniLikeGroup(groupName, this);
717         _groupIndices[groupName] = _groups.length;
718         _groups ~= iniLikeGroup;
719         
720         return iniLikeGroup;
721     }
722     
723     /**
724      * Remove group by name.
725      * See_Also: addGroup, group
726      */
727     @safe void removeGroup(string groupName) nothrow {
728         auto pick = groupName in _groupIndices;
729         if (pick) {
730             _groups[*pick] = null;
731         }
732     }
733     
734     /**
735      * Range of groups in order how they were defined in file.
736      * See_Also: group
737      */
738     @nogc @safe final auto byGroup() const nothrow {
739         return _groups.filter!(f => f !is null);
740     }
741     
742     ///ditto
743     @nogc @safe final auto byGroup() nothrow {
744         return _groups.filter!(f => f !is null);
745     }
746     
747     
748     /**
749      * Save object to the file using .ini-like format.
750      * Throws: ErrnoException if the file could not be opened or an error writing to the file occured.
751      * See_Also: saveToString, save
752      */
753     @trusted void saveToFile(string fileName) const {
754         auto f = File(fileName, "w");
755         void dg(string line) {
756             f.writeln(line);
757         }
758         save(&dg);
759     }
760     
761     /**
762      * Save object to string using .ini like format.
763      * Returns: string representing the contents of file.
764      * See_Also: saveToFile, save
765      */
766     @safe string saveToString() const {
767         auto a = appender!(string[])();
768         void dg(string line) {
769             a.put(line);
770         }
771         save(&dg);
772         return a.data.join("\n");
773     }
774     
775     /**
776      * Alias for saving delegate.
777      * See_Also: save
778      */
779     alias SaveDelegate = void delegate(string);
780     
781     /**
782      * Use delegate to retrieve strings line by line. 
783      * Those strings can be written to the file or be showed in text area.
784      * Note: returned strings don't have trailing newline character.
785      */
786     @trusted void save(SaveDelegate sink) const {
787         foreach(line; firstComments()) {
788             sink(line);
789         }
790         
791         foreach(group; byGroup()) {
792             sink("[" ~ group.name ~ "]");
793             foreach(line; group.byIniLine()) {
794                 if (line.type == IniLikeLine.Type.Comment) {
795                     sink(line.comment);
796                 } else if (line.type == IniLikeLine.Type.KeyValue) {
797                     sink(line.key ~ "=" ~ line.value);
798                 }
799             }
800         }
801     }
802     
803     /**
804      * File path where the object was loaded from.
805      * Returns: file name as was specified on the object creation.
806      */
807     @nogc @safe string fileName() nothrow const {
808         return  _fileName;
809     }
810     
811     /**
812     * Tell whether the string is valid key. For IniLikeFile the valid key is any non-empty string.
813     * Reimplement this function in the derived class to throw exception from IniLikeGroup when key is invalid.
814     */
815     @nogc @safe bool isValidKey(string key) pure nothrow const {
816         return key.length != 0;
817     }
818     
819 protected:
820     @nogc @trusted final auto firstComments() const nothrow {
821         return _firstComments;
822     }
823     
824     @trusted final void addFirstComment(string line) nothrow {
825         _firstComments ~= line;
826     }
827     
828 private:
829     string _fileName;
830     size_t[string] _groupIndices;
831     IniLikeGroup[] _groups;
832     string[] _firstComments;
833 }
834 
835 ///
836 unittest
837 {
838     string contents = 
839 `# The first comment
840 [First Entry]
841 # Comment
842 GenericName=File manager
843 GenericName[ru]=Файловый менеджер
844 # Another comment
845 [Another Group]
846 Name=Commander
847 Comment=Manage files
848 # The last comment`;
849 
850     auto ilf = new IniLikeFile(iniLikeStringReader(contents), IniLikeFile.ReadOptions.preserveComments, "contents.ini");
851     assert(ilf.fileName() == "contents.ini");
852     assert(ilf.group("First Entry"));
853     assert(ilf.group("Another Group"));
854     assert(ilf.saveToString() == contents);
855     
856     auto firstEntry = ilf.group("First Entry");
857     
858     assert(firstEntry["GenericName"] == "File manager");
859     assert(firstEntry.value("GenericName") == "File manager");
860     firstEntry["GenericName"] = "Manager of files";
861     assert(firstEntry["GenericName"] == "Manager of files");
862     firstEntry["Authors"] = "Unknown";
863     assert(firstEntry["Authors"] == "Unknown");
864     
865     assert(firstEntry.localizedValue("GenericName", "ru") == "Файловый менеджер");
866     firstEntry.setLocalizedValue("GenericName", "ru", "Менеджер файлов");
867     assert(firstEntry.localizedValue("GenericName", "ru") == "Менеджер файлов");
868     firstEntry.setLocalizedValue("Authors", "ru", "Неизвестны");
869     assert(firstEntry.localizedValue("Authors", "ru") == "Неизвестны");
870     
871     firstEntry.removeEntry("GenericName");
872     assert(!firstEntry.contains("GenericName"));
873     firstEntry["GenericName"] = "File Manager";
874     assert(firstEntry["GenericName"] == "File Manager");
875     
876     assert(ilf.group("Another Group")["Name"] == "Commander");
877     assert(equal(ilf.group("Another Group").byKeyValue(), [ KeyValueTuple("Name", "Commander"), KeyValueTuple("Comment", "Manage files") ]));
878     
879     assert(equal(ilf.byGroup().map!(g => g.name), ["First Entry", "Another Group"]));
880     
881     ilf.removeGroup("Another Group");
882     assert(!ilf.group("Another Group"));
883     assert(equal(ilf.byGroup().map!(g => g.name), ["First Entry"]));
884     
885     ilf.addGroup("Another Group");
886     assert(ilf.group("Another Group"));
887     assert(ilf.group("Another Group").byIniLine().empty);
888     assert(ilf.group("Another Group").byKeyValue().empty);
889     
890     ilf.addGroup("Other Group");
891     assert(equal(ilf.byGroup().map!(g => g.name), ["First Entry", "Another Group", "Other Group"]));
892     
893     const IniLikeFile cilf = ilf;
894     static assert(is(typeof(cilf.byGroup())));
895     static assert(is(typeof(cilf.group("First Entry").byKeyValue())));
896     static assert(is(typeof(cilf.group("First Entry").byIniLine())));
897     
898     contents = 
899 `[First]
900 Key=Value
901 [Second]
902 Key=Value`;
903     ilf = new IniLikeFile(iniLikeStringReader(contents), IniLikeFile.ReadOptions.firstGroupOnly);
904     assert(ilf.group("First") !is null);
905     assert(ilf.group("Second") is null);
906     assert(ilf.group("First")["Key"] == "Value");
907     
908     contents = 
909 `[Group]
910 GenericName=File manager
911 [Group]
912 Name=Commander`;
913 
914     assertThrown(new IniLikeFile(iniLikeStringReader(contents)));
915     assertNotThrown(new IniLikeFile(iniLikeStringReader(contents), IniLikeFile.ReadOptions.ignoreGroupDuplicates));
916     
917     contents = 
918 `[Group]
919 =File manager`;
920 
921     assertThrown(new IniLikeFile(iniLikeStringReader(contents)));
922     assertNotThrown(new IniLikeFile(iniLikeStringReader(contents), IniLikeFile.ReadOptions.ignoreInvalidKeys));
923 }