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