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