1 /**
2  * Class representation of ini-like file.
3  * Authors: 
4  *  $(LINK2 https://github.com/MyLittleRobo, Roman Chistokhodov)
5  * Copyright:
6  *  Roman Chistokhodov, 2015-2016
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.file;
14 
15 private import std.exception;
16 
17 import inilike.common;
18 public import inilike.range;
19 
20 private @trusted string makeComment(string line) pure nothrow
21 {
22     if (line.length && line[$-1] == '\n') {
23         line = line[0..$-1];
24     }
25     if (!line.isComment && line.length) {
26         line = '#' ~ line;
27     }
28     line = line.replace("\n", " ");
29     return line;
30 }
31 
32 /**
33  * Line in group.
34  */
35 struct IniLikeLine
36 {
37     /**
38      * Type of line.
39      */
40     enum Type
41     {
42         None = 0,   /// deleted or invalid line
43         Comment = 1, /// a comment or empty line
44         KeyValue = 2 /// key-value pair
45     }
46     
47     /**
48      * Contruct from comment.
49      */
50     @nogc @safe static IniLikeLine fromComment(string comment) nothrow pure {
51         return IniLikeLine(comment, null, Type.Comment);
52     }
53     
54     /**
55      * Construct from key and value.
56      */
57     @nogc @safe static IniLikeLine fromKeyValue(string key, string value) nothrow pure {
58         return IniLikeLine(key, value, Type.KeyValue);
59     }
60     
61     /**
62      * Get comment.
63      * Returns: Comment or empty string if type is not Type.Comment.
64      */
65     @nogc @safe string comment() const nothrow pure {
66         return _type == Type.Comment ? _first : null;
67     }
68     
69     /**
70      * Get key.
71      * Returns: Key or empty string if type is not Type.KeyValue
72      */
73     @nogc @safe string key() const nothrow pure {
74         return _type == Type.KeyValue ? _first : null;
75     }
76     
77     /**
78      * Get value.
79      * Returns: Value or empty string if type is not Type.KeyValue
80      */
81     @nogc @safe string value() const nothrow pure {
82         return _type == Type.KeyValue ? _second : null;
83     }
84     
85     /**
86      * Get type of line.
87      */
88     @nogc @safe Type type() const nothrow pure {
89         return _type;
90     }
91     
92     /**
93      * Assign Type.None to line.
94      */
95     @nogc @safe void makeNone() nothrow pure {
96         _type = Type.None;
97     }
98 private:
99     string _first;
100     string _second;
101     Type _type = Type.None;
102 }
103 
104 
105 /**
106  * This class represents the group (section) in the ini-like file. 
107  * You can create and use instances of this class only in the context of $(B IniLikeFile) or its derivatives.
108  * Note: Keys are case-sensitive.
109  */
110 class IniLikeGroup
111 {
112 public:
113     /**
114      * Create instance on IniLikeGroup and set its name to groupName.
115      */
116     protected @nogc @safe this(string groupName) nothrow {
117         _name = groupName;
118     }
119     
120     /**
121      * Returns: The value associated with the key.
122      * Note: The value is not unescaped automatically.
123      * Warning: It's an error to access nonexistent value.
124      * See_Also: value
125      */
126     @nogc @safe final string opIndex(string key) const nothrow pure {
127         auto i = key in _indices;
128         assert(_values[*i].type == IniLikeLine.Type.KeyValue);
129         assert(_values[*i].key == key);
130         return _values[*i].value;
131     }
132     
133     private @safe final string setKeyValueImpl(string key, string value) nothrow pure
134     in {
135         assert(!value.needEscaping);
136     }
137     body {
138         auto pick = key in _indices;
139         if (pick) {
140             return (_values[*pick] = IniLikeLine.fromKeyValue(key, value)).value;
141         } else {
142             _indices[key] = _values.length;
143             _values ~= IniLikeLine.fromKeyValue(key, value);
144             return value;
145         }
146     }
147     
148     /**
149      * Insert new value or replaces the old one if value associated with key already exists.
150      * Note: The value is not escaped automatically upon writing. It's your responsibility to escape it.
151      * Returns: Inserted/updated value or null string if key was not added.
152      * Throws: IniLikeEntryException if key or value is not valid or value needs to be escaped.
153      * See_Also: writeEntry
154      */
155     @safe final string opIndexAssign(string value, string key) {
156         validateKeyAndValue(key, value);
157         return setKeyValueImpl(key, value);
158     }
159     
160     /**
161      * Assign localized value.
162      * Note: The value is not escaped automatically upon writing. It's your responsibility to escape it.
163      * See_Also: setLocalizedValue, localizedValue
164      */
165     @safe final string opIndexAssign(string value, string key, string locale) {
166         string keyName = localizedKey(key, locale);
167         return this[keyName] = value;
168     }
169     
170     /**
171      * Tell if group contains value associated with the key.
172      */
173     @nogc @safe final bool contains(string key) const nothrow pure {
174         return value(key) !is null;
175     }
176     
177     /**
178      * Get value by key.
179      * Returns: The value associated with the key, or defaultValue if group does not contain such item.
180      * Note: The value is not unescaped automatically.
181      * See_Also: readEntry, localizedValue
182      */
183     @nogc @safe final string value(string key, string defaultValue = null) const nothrow pure {
184         auto pick = key in _indices;
185         if (pick) {
186             if(_values[*pick].type == IniLikeLine.Type.KeyValue) {
187                 assert(_values[*pick].key == key);
188                 return _values[*pick].value;
189             }
190         }
191         return defaultValue;
192     }
193     
194     /**
195      * Get value by key. This function automatically unescape the found value before returning.
196      * Returns: The unescaped value associated with key or null if not found.
197      * See_Also: value
198      */
199     @safe final string readEntry(string key, string locale = null) const nothrow pure {
200         if (locale.length) {
201             return localizedValue(key, locale).unescapeValue();
202         } else {
203             return value(key).unescapeValue();
204         }
205     }
206     
207     /**
208      * Set value by key. This function automatically escape the value (you should not escape value yourself) when writing it.
209      * Throws: IniLikeEntryException if key or value is not valid.
210      */
211     @safe final string writeEntry(string key, string value, string locale = null) {
212         value = value.escapeValue();
213         validateKeyAndValue(key, value);
214         string keyName = localizedKey(key, locale);
215         return setKeyValueImpl(keyName, value);
216     }
217     
218     /**
219      * Perform locale matching lookup as described in $(LINK2 http://standards.freedesktop.org/desktop-entry-spec/latest/ar01s04.html, Localized values for keys).
220      * Params:
221      *  key = Non-localized key.
222      *  locale = Locale in intereset.
223      *  nonLocaleFallback = Allow fallback to non-localized version.
224      * Returns: The localized value associated with key and locale, 
225      * or the value associated with non-localized key if group does not contain localized value and nonLocaleFallback is true.
226      * Note: The value is not unescaped automatically.
227      * See_Also: value
228      */
229     @safe final string localizedValue(string key, string locale, bool nonLocaleFallback = true) const nothrow pure {
230         //Any ideas how to get rid of this boilerplate and make less allocations?
231         const t = parseLocaleName(locale);
232         auto lang = t.lang;
233         auto country = t.country;
234         auto modifier = t.modifier;
235         
236         if (lang.length) {
237             string pick;
238             if (country.length && modifier.length) {
239                 pick = value(localizedKey(key, locale));
240                 if (pick !is null) {
241                     return pick;
242                 }
243             }
244             if (country.length) {
245                 pick = value(localizedKey(key, lang, country));
246                 if (pick !is null) {
247                     return pick;
248                 }
249             }
250             if (modifier.length) {
251                 pick = value(localizedKey(key, lang, string.init, modifier));
252                 if (pick !is null) {
253                     return pick;
254                 }
255             }
256             pick = value(localizedKey(key, lang, string.init));
257             if (pick !is null) {
258                 return pick;
259             }
260         }
261         
262         if (nonLocaleFallback) {
263             return value(key);
264         } else {
265             return null;
266         }
267     }
268     
269     ///
270     unittest 
271     {
272         auto lilf = new IniLikeFile;
273         lilf.addGroup("Entry");
274         auto group = lilf.group("Entry");
275         assert(group.groupName == "Entry"); 
276         group["Name"] = "Programmer";
277         group["Name[ru_RU]"] = "Разработчик";
278         group["Name[ru@jargon]"] = "Кодер";
279         group["Name[ru]"] = "Программист";
280         group["Name[de_DE@dialect]"] = "Programmierer"; //just example
281         group["Name[fr_FR]"] = "Programmeur";
282         group["GenericName"] = "Program";
283         group["GenericName[ru]"] = "Программа";
284         assert(group["Name"] == "Programmer");
285         assert(group.localizedValue("Name", "ru@jargon") == "Кодер");
286         assert(group.localizedValue("Name", "ru_RU@jargon") == "Разработчик");
287         assert(group.localizedValue("Name", "ru") == "Программист");
288         assert(group.localizedValue("Name", "ru_RU.UTF-8") == "Разработчик");
289         assert(group.localizedValue("Name", "nonexistent locale") == "Programmer");
290         assert(group.localizedValue("Name", "de_DE@dialect") == "Programmierer");
291         assert(group.localizedValue("Name", "fr_FR.UTF-8") == "Programmeur");
292         assert(group.localizedValue("GenericName", "ru_RU") == "Программа");
293         assert(group.localizedValue("GenericName", "fr_FR") == "Program");
294         assert(group.localizedValue("GenericName", "fr_FR", false) is null);
295     }
296     
297     /**
298      * Same as localized version of opIndexAssign, but uses function syntax.
299      * Note: The value is not escaped automatically upon writing. It's your responsibility to escape it.
300      * Throws: IniLikeEntryException if key or value is not valid or value needs to be escaped.
301      * See_Also: writeEntry
302      */
303     @safe final void setLocalizedValue(string key, string locale, string value) {
304         this[key, locale] = value;
305     }
306     
307     /**
308      * Removes entry by key. Do nothing if not value associated with key found.
309      * Returns: true if entry was removed, false otherwise.
310      */
311     @safe final bool removeEntry(string key) nothrow pure {
312         auto pick = key in _indices;
313         if (pick) {
314             _values[*pick].makeNone();
315             return true;
316         }
317         return false;
318     }
319     
320     ///ditto, but remove entry by localized key
321     @safe final void removeEntry(string key, string locale) nothrow pure {
322         removeEntry(localizedKey(key, locale));
323     }
324     
325     /**
326      * Remove all entries satisying ToDelete function. 
327      * ToDelete should be function accepting string key and value and return boolean.
328      */
329     final void removeEntries(alias ToDelete)()
330     {
331         IniLikeLine[] values;
332         
333         foreach(line; _values) {
334             if (line.type == IniLikeLine.Type.KeyValue && ToDelete(line.key, line.value)) {
335                 _indices.remove(line.key);
336                 continue;
337             }
338             if (line.type == IniLikeLine.Type.None) {
339                 continue;
340             }
341             values ~= line;
342         }
343         
344         _values = values;
345         foreach(i, line; _values) {
346             if (line.type == IniLikeLine.Type.KeyValue) {
347                 _indices[line.key] = i;
348             }
349         }
350     }
351     
352     ///
353     unittest
354     {
355         string contents = 
356 `[Group]
357 Key1=Value1
358 Name=Value
359 # Comment
360 ToRemove=Value
361 Key2=Value2
362 NameGeneric=Value
363 Key3=Value3`;
364         auto ilf = new IniLikeFile(iniLikeStringReader(contents));
365         assert(ilf.group("Group").removeEntry("ToRemove"));
366         assert(!ilf.group("Group").removeEntry("NonExistent"));
367         ilf.group("Group").removeEntries!(function bool(string key, string value) {
368             return key.startsWith("Name");
369         })();
370         
371         auto group = ilf.group("Group");
372         
373         assert(group.value("Key1") == "Value1");
374         assert(group.value("Key2") == "Value2");
375         assert(group.value("Key3") == "Value3");
376         assert(equal(group.byIniLine(), [
377                     IniLikeLine.fromKeyValue("Key1", "Value1"), IniLikeLine.fromComment("# Comment"), 
378                     IniLikeLine.fromKeyValue("Key2", "Value2"), IniLikeLine.fromKeyValue("Key3", "Value3")]));
379         assert(!group.contains("Name"));
380         assert(!group.contains("NameGeneric"));
381     }
382     
383     /**
384      * Iterate by Key-Value pairs.
385      * Returns: Range of Tuple!(string, "key", string, "value").
386      * See_Also: value, localizedValue
387      */
388     @nogc @safe final auto byKeyValue() const nothrow {
389         return staticByKeyValue(_values);
390     }
391     
392     /**
393      * Empty range of the same type as byKeyValue. Can be used in derived classes if it's needed to have empty range.
394      * Returns: Empty range of Tuple!(string, "key", string, "value").
395      */
396     @nogc @safe static auto emptyByKeyValue() nothrow {
397         return staticByKeyValue((IniLikeLine[]).init);
398     }
399     
400     ///
401     unittest
402     {
403         assert(emptyByKeyValue().empty);
404         auto group = new IniLikeGroup("Group name");
405         static assert(is(typeof(emptyByKeyValue()) == typeof(group.byKeyValue()) ));
406     }
407     
408     private @nogc @safe static auto staticByKeyValue(const(IniLikeLine)[] values) nothrow {
409         return values.filter!(v => v.type == IniLikeLine.Type.KeyValue).map!(v => keyValueTuple(v.key, v.value));
410     }
411     
412     /**
413      * Get name of this group.
414      * Returns: The name of this group.
415      */
416     @nogc @safe final string groupName() const nothrow pure {
417         return _name;
418     }
419     
420     /**
421      * Returns: Range of $(B IniLikeLine)s included in this group.
422      */
423     @trusted final auto byIniLine() const {
424         return _values.filter!(v => v.type != IniLikeLine.Type.None);
425     }
426     
427     /**
428      * Add comment line into the group.
429      * Returns: Line added as comment.
430      * See_Also: byIniLine, prependComment
431      */
432     @safe final string appendComment(string comment) nothrow pure {
433         _values ~= IniLikeLine.fromComment(makeComment(comment));
434         return _values[$-1].comment();
435     }
436     
437     /**
438      * Add comment line at the start of group (after group header, before any key-value pairs).
439      * Returns: Line added as comment.
440      * See_Also: byIniLine, appendComment
441      */
442     @safe final string prependComment(string comment) nothrow pure {
443         _values = IniLikeLine.fromComment(makeComment(comment)) ~ _values;
444         return _values[0].comment();
445     }
446     
447 protected:
448     /**
449      * Validate key before setting value to key for this group and throw exception if not valid.
450      * Can be reimplemented in derived classes. 
451      * Default implementation checks if key is not empty string, does not look like comment and does not contain new line or carriage return characters.
452      * Params:
453      *  key = key to validate.
454      *  value = value that is being set to key.
455      * Throws: IniLikeEntryException if either key is invalid.
456      * See_Also: validateValue
457      */
458     @trusted void validateKey(string key, string value) const {
459         if (key.empty || key.strip.empty) {
460             throw new IniLikeEntryException("key must not be empty", _name, key, value);
461         }
462         if (key.isComment()) {
463             throw new IniLikeEntryException("key must not start with #", _name, key, value);
464         }
465         if (key.canFind('=')) {
466             throw new IniLikeEntryException("key must not have '=' character in it", _name, key, value);
467         }
468         if (key.needEscaping()) {
469             throw new IniLikeEntryException("key must not contain new line characters", _name, key, value);
470         }
471     }
472     
473     ///
474     unittest
475     {
476         auto ilf = new IniLikeFile();
477         ilf.addGroup("Group");
478         
479         auto entryException = collectException!IniLikeEntryException(ilf.group("Group")[""] = "Value1");
480         assert(entryException !is null);
481         assert(entryException.groupName == "Group");
482         assert(entryException.key == "");
483         assert(entryException.value == "Value1");
484         
485         entryException = collectException!IniLikeEntryException(ilf.group("Group")["    "] = "Value2");
486         assert(entryException !is null);
487         assert(entryException.key == "    ");
488         assert(entryException.value == "Value2");
489         
490         entryException = collectException!IniLikeEntryException(ilf.group("Group")["New\nLine"] = "Value3");
491         assert(entryException !is null);
492         assert(entryException.key == "New\nLine");
493         assert(entryException.value == "Value3");
494         
495         entryException = collectException!IniLikeEntryException(ilf.group("Group")["# Comment"] = "Value4");
496         assert(entryException !is null);
497         assert(entryException.key == "# Comment");
498         assert(entryException.value == "Value4");
499         
500         entryException = collectException!IniLikeEntryException(ilf.group("Group")["Everyone=Is"] = "Equal");
501         assert(entryException !is null);
502         assert(entryException.key == "Everyone=Is");
503         assert(entryException.value == "Equal");
504     }
505     
506     /**
507      * Validate value for key before setting value to key for this group and throw exception if not valid.
508      * Can be reimplemented in derived classes. 
509      * Default implementation checks if value is escaped.
510      * Params:
511      *  key = key the value is being set to.
512      *  value = value to validate. Considered to be escaped.
513      * Throws: IniLikeEntryException if value is invalid.
514      * See_Also: validateKey
515      */
516     @trusted void validateValue(string key, string value) const {
517         if (value.needEscaping()) {
518             throw new IniLikeEntryException("The value needs to be escaped", _name, key, value);
519         }
520     }
521     
522     ///
523     unittest
524     {
525         auto ilf = new IniLikeFile();
526         ilf.addGroup("Group");
527         
528         auto entryException = collectException!IniLikeEntryException(ilf.group("Group")["Key"] = "New\nline");
529         assert(entryException !is null);
530         assert(entryException.key == "Key");
531         assert(entryException.value == "New\nline");
532     }
533     
534     /**
535      * Utility function that calls validateKey and validateValue.
536      * See_Also: validateKey, validateValue
537      */
538     @safe final void validateKeyAndValue(string key, string value) const {
539         validateKey(key, value);
540         validateValue(key, value);
541     }
542     
543 private:
544     size_t[string] _indices;
545     IniLikeLine[] _values;
546     string _name;
547 }
548 
549 ///Base class for ini-like format errors.
550 class IniLikeException : Exception
551 {
552     ///
553     this(string msg, string file = __FILE__, size_t line = __LINE__, Throwable next = null) pure nothrow @safe {
554         super(msg, file, line, next);
555     }
556 }
557 
558 /**
559  * Exception thrown on the file read error.
560  */
561 class IniLikeReadException : IniLikeException
562 {
563     /**
564      * Create IniLikeReadException with msg, lineNumber and fileName.
565      */
566     this(string msg, size_t lineNumber, string fileName = null, IniLikeEntryException entryException = null, string file = __FILE__, size_t line = __LINE__, Throwable next = null) pure nothrow @safe {
567         super(msg, file, line, next);
568         _lineNumber = lineNumber;
569         _fileName = fileName;
570         _entryException = entryException;
571     }
572     
573     /** 
574      * Number of line in the file where the exception occured, starting from 1.
575      * 0 means that error is not bound to any existing line, but instead relate to file at whole (e.g. required group or key is missing).
576      * Don't confuse with $(B line) property of $(B Throwable).
577      */
578     @nogc @safe size_t lineNumber() const nothrow pure {
579         return _lineNumber;
580     }
581     
582     /**
583      * Number of line in the file where the exception occured, starting from 0. 
584      * Don't confuse with $(B line) property of $(B Throwable).
585      */
586     @nogc @safe size_t lineIndex() const nothrow pure {
587         return _lineNumber ? _lineNumber - 1 : 0;
588     }
589     
590     /**
591      * Name of ini-like file where error occured. 
592      * Can be empty if fileName was not given upon IniLikeFile creating.
593      * Don't confuse with $(B file) property of $(B Throwable).
594      */
595     @nogc @safe string fileName() const nothrow pure {
596         return _fileName;
597     }
598     
599     /**
600      * Original IniLikeEntryException which caused this error.
601      * This will have the same msg.
602      * Returns: IniLikeEntryException object or null if the cause of error was something else.
603      */
604     @nogc @safe IniLikeEntryException entryException() nothrow pure {
605         return _entryException;
606     }
607     
608 private:
609     size_t _lineNumber;
610     string _fileName;
611     IniLikeEntryException _entryException;
612 }
613 
614 /**
615  * Exception thrown when trying to set invalid key or value.
616  */
617 class IniLikeEntryException : IniLikeException
618 {
619     this(string msg, string group, string key, string value, string file = __FILE__, size_t line = __LINE__, Throwable next = null) pure nothrow @safe {
620         super(msg, file, line, next);
621         _group = group;
622         _key = key;
623         _value = value;
624     }
625     
626     /**
627      * The key the value associated with.
628      */
629     @nogc @safe string key() const nothrow pure {
630         return _key;
631     }
632     
633     /**
634      * The value associated with key.
635      */
636     @nogc @safe string value() const nothrow pure {
637         return _value;
638     }
639     
640     /**
641      * Name of group where error occured.
642      */
643     @nogc @safe string groupName() const nothrow pure {
644         return _group;
645     }
646     
647 private:
648     string _group;
649     string _key;
650     string _value;
651 }
652 
653 /**
654  * Ini-like file.
655  * 
656  */
657 class IniLikeFile
658 {
659 protected:
660     /**
661      * Add comment for group.
662      * This function is called only in constructor and can be reimplemented in derived classes.
663      * Params:
664      *  comment = Comment line to add.
665      *  currentGroup = The group returned recently by createGroup during parsing. Can be null (e.g. if discarded)
666      *  groupName = The name of the currently parsed group. Set even if currentGroup is null.
667      * See_Also: createGroup, IniLikeGroup.appendComment
668      */
669     @trusted void addCommentForGroup(string comment, IniLikeGroup currentGroup, string groupName)
670     {
671         if (currentGroup) {
672             currentGroup.appendComment(comment);
673         }
674     }
675     
676     /**
677      * Add key/value pair for group.
678      * This function is called only in constructor and can be reimplemented in derived classes.
679      * Params:
680      *  key = Key to insert or set.
681      *  value = Value to set for key.
682      *  currentGroup = The group returned recently by createGroup during parsing. Can be null (e.g. if discarded)
683      *  groupName = The name of the currently parsed group. Set even if currentGroup is null.
684      * See_Also: createGroup
685      */
686     @trusted void addKeyValueForGroup(string key, string value, IniLikeGroup currentGroup, string groupName)
687     {
688         if (currentGroup) {
689             if (currentGroup.contains(key)) {
690                 throw new Exception("key already exists");
691             }
692             currentGroup[key] = value;
693         }
694     }
695     
696     /**
697      * Create iniLikeGroup by groupName.
698      * This function is called only in constructor and can be reimplemented in derived classes, 
699      * e.g. to insert additional checks or create specific derived class depending on groupName.
700      * Returned value is later passed to addCommentForGroup and addKeyValueForGroup methods as currentGroup. 
701      * Reimplemented method also is allowed to return null.
702      * Default implementation just returns empty IniLikeGroup with name set to groupName.
703      * Throws:
704      *  IniLikeException if group with such name already exists.
705      * See_Also:
706      *  addKeyValueForGroup, addCommentForGroup
707      */
708     @trusted IniLikeGroup createGroup(string groupName)
709     {
710         if (group(groupName) !is null) {
711             throw new IniLikeException("group already exists");
712         }
713         return createEmptyGroup(groupName);
714     }
715     
716     /**
717      * Can be used in derived classes to create instance of IniLikeGroup.
718      */
719     @safe static createEmptyGroup(string groupName) {
720         return new IniLikeGroup(groupName);
721     }
722     
723 public:
724     /**
725      * Construct empty IniLikeFile, i.e. without any groups or values
726      */
727     @nogc @safe this() nothrow {
728         
729     }
730     
731     /**
732      * Read from file.
733      * Throws:
734      *  $(B ErrnoException) if file could not be opened.
735      *  $(B IniLikeReadException) if error occured while reading the file.
736      */
737     @trusted this(string fileName) {
738         this(iniLikeFileReader(fileName), fileName);
739     }
740     
741     /**
742      * Read from range of inilike.range.IniLikeReader.
743      * Note: All exceptions thrown within constructor are turning into IniLikeReadException.
744      * Throws:
745      *  $(B IniLikeReadException) if error occured while parsing.
746      */
747     this(IniLikeReader)(IniLikeReader reader, string fileName = null)
748     {
749         size_t lineNumber = 0;
750         IniLikeGroup currentGroup;
751         
752         version(DigitalMars) {
753             static void foo(size_t ) {}
754         }
755         
756         try {
757             foreach(line; reader.byLeadingLines)
758             {
759                 lineNumber++;
760                 if (line.isComment || line.strip.empty) {
761                     appendLeadingComment(line);
762                 } else {
763                     throw new IniLikeException("Expected comment or empty line before any group");
764                 }
765             }
766             
767             foreach(g; reader.byGroup)
768             {
769                 lineNumber++;
770                 string groupName = g.groupName;
771                 
772                 version(DigitalMars) {
773                     foo(lineNumber); //fix dmd codgen bug with -O
774                 }
775                 
776                 currentGroup = addGroup(groupName);
777                 
778                 foreach(line; g.byEntry)
779                 {
780                     lineNumber++;
781                     
782                     if (line.isComment || line.strip.empty) {
783                         addCommentForGroup(line, currentGroup, groupName);
784                     } else {
785                         const t = parseKeyValue(line);
786                         
787                         string key = t.key.stripRight;
788                         string value = t.value.stripLeft;
789                         
790                         if (key.length == 0 && value.length == 0) {
791                             throw new IniLikeException("Expected comment, empty line or key value inside group");
792                         } else {
793                             addKeyValueForGroup(key, value, currentGroup, groupName);
794                         }
795                     }
796                 }
797             }
798             
799             _fileName = fileName;
800             
801         }
802         catch(IniLikeEntryException e) {
803             throw new IniLikeReadException(e.msg, lineNumber, fileName, e, e.file, e.line, e.next);
804         }
805         catch (Exception e) {
806             throw new IniLikeReadException(e.msg, lineNumber, fileName, null, e.file, e.line, e.next);
807         }
808     }
809     
810     /**
811      * Get group by name.
812      * Returns: IniLikeGroup instance associated with groupName or $(B null) if not found.
813      * See_Also: byGroup
814      */
815     @nogc @safe final inout(IniLikeGroup) group(string groupName) nothrow inout pure {
816         auto pick = groupName in _groupIndices;
817         if (pick) {
818             return _groups[*pick];
819         }
820         return null;
821     }
822     
823     /**
824      * Create new group using groupName.
825      * Returns: Newly created instance of IniLikeGroup.
826      * Throws: IniLikeException if group with such name already exists or groupName is empty.
827      * See_Also: removeGroup, group
828      */
829     @safe final IniLikeGroup addGroup(string groupName) {
830         if (groupName.length == 0) {
831             throw new IniLikeException("empty group name");
832         }
833         
834         auto iniLikeGroup = createGroup(groupName);
835         if (iniLikeGroup !is null) {
836             _groupIndices[groupName] = _groups.length;
837             _groups ~= iniLikeGroup;
838         }
839         return iniLikeGroup;
840     }
841     
842     /**
843      * Remove group by name. Do nothing if group with such name does not exist.
844      * Returns: true if group was deleted, false otherwise.
845      * See_Also: addGroup, group
846      */
847     @safe bool removeGroup(string groupName) nothrow {
848         auto pick = groupName in _groupIndices;
849         if (pick) {
850             _groups[*pick] = null;
851             return true;
852         } else {
853             return false;
854         }
855     }
856     
857     /**
858      * Range of groups in order how they were defined in file.
859      * See_Also: group
860      */
861     @nogc @safe final auto byGroup() const nothrow {
862         return _groups.filter!(f => f !is null);
863     }
864     
865     ///ditto
866     @nogc @safe final auto byGroup() nothrow {
867         return _groups.filter!(f => f !is null);
868     }
869     
870     
871     /**
872      * Save object to the file using .ini-like format.
873      * Throws: ErrnoException if the file could not be opened or an error writing to the file occured.
874      * See_Also: saveToString, save
875      */
876     @trusted final void saveToFile(string fileName) const {
877         import std.stdio : File;
878         
879         auto f = File(fileName, "w");
880         void dg(in string line) {
881             f.writeln(line);
882         }
883         save(&dg);
884     }
885     
886     /**
887      * Save object to string using .ini like format.
888      * Returns: A string that represents the contents of file.
889      * See_Also: saveToFile, save
890      */
891     @trusted final string saveToString() const {
892         auto a = appender!(string[])();
893         save(a);
894         return a.data.join("\n");
895     }
896     
897     /**
898      * Use Output range or delegate to retrieve strings line by line. 
899      * Those strings can be written to the file or be showed in text area.
900      * Note: returned strings don't have trailing newline character.
901      */
902     final void save(OutRange)(OutRange sink) const if (isOutputRange!(OutRange, string)) {
903         foreach(line; leadingComments()) {
904             put(sink, line);
905         }
906         
907         foreach(group; byGroup()) {
908             put(sink, "[" ~ group.groupName ~ "]");
909             foreach(line; group.byIniLine()) {
910                 if (line.type == IniLikeLine.Type.Comment) {
911                     put(sink, line.comment);
912                 } else if (line.type == IniLikeLine.Type.KeyValue) {
913                     put(sink, line.key ~ "=" ~ line.value);
914                 }
915             }
916         }
917     }
918     
919     /**
920      * File path where the object was loaded from.
921      * Returns: File name as was specified on the object creation.
922      */
923     @nogc @safe final string fileName() nothrow const pure {
924         return _fileName;
925     }
926     
927     /**
928      * Leading comments.
929      * Returns: Range of leading comments (before any group)
930      * See_Also: appendLeadingComment, prependLeadingComment
931      */
932     @nogc @safe final auto leadingComments() const nothrow pure {
933         return _leadingComments;
934     }
935     
936     ///
937     unittest
938     {
939         auto ilf = new IniLikeFile();
940         assert(ilf.appendLeadingComment("First") == "#First");
941         assert(ilf.appendLeadingComment("#Second") == "#Second");
942         assert(ilf.appendLeadingComment("Sneaky\nKey=Value") == "#Sneaky Key=Value");
943         assert(ilf.appendLeadingComment("# New Line\n") == "# New Line");
944         assert(ilf.appendLeadingComment("") == "");
945         assert(ilf.appendLeadingComment("\n") == "");
946         assert(ilf.prependLeadingComment("Shebang") == "#Shebang");
947         assert(ilf.leadingComments().equal(["#Shebang", "#First", "#Second", "#Sneaky Key=Value", "# New Line", "", ""]));
948         ilf.clearLeadingComments();
949         assert(ilf.leadingComments().empty);
950     }
951     
952     /**
953      * Add leading comment. This will be appended to the list of leadingComments.
954      * Note: # will be prepended automatically if line is not empty and does not have # at the start. 
955      *  The last new line character will be removed if present. Others will be replaced with whitespaces.
956      * Returns: Line that was added as comment.
957      * See_Also: leadingComments, prependLeadingComment
958      */
959     @safe string appendLeadingComment(string line) nothrow {
960         line = makeComment(line);
961         _leadingComments ~= line;
962         return line;
963     }
964     
965     /**
966      * Prepend leading comment (e.g. for setting shebang line).
967      * Returns: Line that was added as comment.
968      * See_Also: leadingComments, appendLeadingComment
969      */
970     @safe string prependLeadingComment(string line) nothrow pure {
971         line = makeComment(line);
972         _leadingComments = line ~ _leadingComments;
973         return line;
974     }
975     
976     /**
977      * Remove all coments met before groups.
978      */
979     @nogc final @safe void clearLeadingComments() nothrow {
980         _leadingComments = null;
981     }
982     
983 private:
984     string _fileName;
985     size_t[string] _groupIndices;
986     IniLikeGroup[] _groups;
987     string[] _leadingComments;
988 }
989 
990 ///
991 unittest
992 {
993     import std.file;
994     import std.path;
995     
996     string contents = 
997 `# The first comment
998 [First Entry]
999 # Comment
1000 GenericName=File manager
1001 GenericName[ru]=Файловый менеджер
1002 NeedUnescape=yes\\i\tneed
1003 NeedUnescape[ru]=да\\я\tнуждаюсь
1004 # Another comment
1005 [Another Group]
1006 Name=Commander
1007 Comment=Manage files
1008 # The last comment`;
1009 
1010     auto ilf = new IniLikeFile(iniLikeStringReader(contents), "contents.ini");
1011     assert(ilf.fileName() == "contents.ini");
1012     assert(equal(ilf.leadingComments(), ["# The first comment"]));
1013     assert(ilf.group("First Entry"));
1014     assert(ilf.group("Another Group"));
1015     assert(ilf.saveToString() == contents);
1016     
1017     string tempFile = buildPath(tempDir(), "inilike-unittest-tempfile");
1018     try {
1019         assertNotThrown!IniLikeReadException(ilf.saveToFile(tempFile));
1020         auto fileContents = cast(string)std.file.read(tempFile);
1021         static if( __VERSION__ < 2067 ) {
1022             assert(equal(fileContents.splitLines, contents.splitLines), "Contents should be preserved as is");
1023         } else {
1024             assert(equal(fileContents.lineSplitter, contents.lineSplitter), "Contents should be preserved as is");
1025         }
1026         
1027         IniLikeFile filf; 
1028         assertNotThrown!IniLikeReadException(filf = new IniLikeFile(tempFile));
1029         assert(filf.fileName() == tempFile);
1030         remove(tempFile);
1031     } catch(Exception e) {
1032         //environmental error in unittests
1033     }
1034     
1035     auto firstEntry = ilf.group("First Entry");
1036     
1037     assert(!firstEntry.contains("NonExistent"));
1038     assert(firstEntry.contains("GenericName"));
1039     assert(firstEntry.contains("GenericName[ru]"));
1040     assert(firstEntry["GenericName"] == "File manager");
1041     assert(firstEntry.value("GenericName") == "File manager");
1042     
1043     assert(firstEntry.value("NeedUnescape") == `yes\\i\tneed`);
1044     assert(firstEntry.readEntry("NeedUnescape") == "yes\\i\tneed");
1045     assert(firstEntry.localizedValue("NeedUnescape", "ru") == `да\\я\tнуждаюсь`);
1046     assert(firstEntry.readEntry("NeedUnescape", "ru") == "да\\я\tнуждаюсь");
1047     
1048     firstEntry.writeEntry("NeedEscape", "i\rneed\nescape");
1049     assert(firstEntry.value("NeedEscape") == `i\rneed\nescape`);
1050     firstEntry.writeEntry("NeedEscape", "мне\rнужно\nэкранирование");
1051     assert(firstEntry.localizedValue("NeedEscape", "ru") == `мне\rнужно\nэкранирование`);
1052     
1053     firstEntry["GenericName"] = "Manager of files";
1054     assert(firstEntry["GenericName"] == "Manager of files");
1055     firstEntry["Authors"] = "Unknown";
1056     assert(firstEntry["Authors"] == "Unknown");
1057     
1058     assert(firstEntry.localizedValue("GenericName", "ru") == "Файловый менеджер");
1059     firstEntry.setLocalizedValue("GenericName", "ru", "Менеджер файлов");
1060     assert(firstEntry.localizedValue("GenericName", "ru") == "Менеджер файлов");
1061     firstEntry.setLocalizedValue("Authors", "ru", "Неизвестны");
1062     assert(firstEntry.localizedValue("Authors", "ru") == "Неизвестны");
1063     
1064     firstEntry.removeEntry("GenericName");
1065     assert(!firstEntry.contains("GenericName"));
1066     firstEntry.removeEntry("GenericName", "ru");
1067     assert(!firstEntry.contains("GenericName[ru]"));
1068     firstEntry["GenericName"] = "File Manager";
1069     assert(firstEntry["GenericName"] == "File Manager");
1070     
1071     assert(ilf.group("Another Group")["Name"] == "Commander");
1072     assert(equal(ilf.group("Another Group").byKeyValue(), [ keyValueTuple("Name", "Commander"), keyValueTuple("Comment", "Manage files") ]));
1073     
1074     assert(ilf.group("Another Group").appendComment("The lastest comment"));
1075     assert(ilf.group("Another Group").prependComment("The first comment"));
1076     
1077     assert(equal(
1078         ilf.group("Another Group").byIniLine(), 
1079         [IniLikeLine.fromComment("#The first comment"), IniLikeLine.fromKeyValue("Name", "Commander"), IniLikeLine.fromKeyValue("Comment", "Manage files"), IniLikeLine.fromComment("# The last comment"), IniLikeLine.fromComment("#The lastest comment")]
1080     ));
1081     
1082     assert(equal(ilf.byGroup().map!(g => g.groupName), ["First Entry", "Another Group"]));
1083     
1084     assert(!ilf.removeGroup("NonExistent Group"));
1085     
1086     assert(ilf.removeGroup("Another Group"));
1087     assert(!ilf.group("Another Group"));
1088     assert(equal(ilf.byGroup().map!(g => g.groupName), ["First Entry"]));
1089     
1090     ilf.addGroup("Another Group");
1091     assert(ilf.group("Another Group"));
1092     assert(ilf.group("Another Group").byIniLine().empty);
1093     assert(ilf.group("Another Group").byKeyValue().empty);
1094     
1095     ilf.addGroup("Other Group");
1096     assert(equal(ilf.byGroup().map!(g => g.groupName), ["First Entry", "Another Group", "Other Group"]));
1097     
1098     assertThrown!IniLikeException(ilf.addGroup(""));
1099     
1100     const IniLikeFile cilf = ilf;
1101     static assert(is(typeof(cilf.byGroup())));
1102     static assert(is(typeof(cilf.group("First Entry").byKeyValue())));
1103     static assert(is(typeof(cilf.group("First Entry").byIniLine())));
1104     
1105     contents = 
1106 `[Group]
1107 GenericName=File manager
1108 [Group]
1109 GenericName=Commander`;
1110 
1111     auto shouldThrow = collectException!IniLikeReadException(new IniLikeFile(iniLikeStringReader(contents), "config.ini"));
1112     assert(shouldThrow !is null, "Duplicate groups should throw");
1113     assert(shouldThrow.lineNumber == 3);
1114     assert(shouldThrow.lineIndex == 2);
1115     assert(shouldThrow.fileName == "config.ini");
1116     
1117     contents = 
1118 `[Group]
1119 Key=Value1
1120 Key=Value2`;
1121 
1122     shouldThrow = collectException!IniLikeReadException(new IniLikeFile(iniLikeStringReader(contents)));
1123     assert(shouldThrow !is null, "Duplicate key should throw");
1124     assert(shouldThrow.lineNumber == 3);
1125     
1126     contents =
1127 `[Group]
1128 Key=Value
1129 =File manager`;
1130 
1131     shouldThrow = collectException!IniLikeReadException(new IniLikeFile(iniLikeStringReader(contents)));
1132     assert(shouldThrow !is null, "Empty key should throw");
1133     assert(shouldThrow.lineNumber == 3);
1134     
1135     contents = 
1136 `[Group]
1137 #Comment
1138 Valid=Key
1139 NotKeyNotGroupNotComment`;
1140 
1141     shouldThrow = collectException!IniLikeReadException(new IniLikeFile(iniLikeStringReader(contents)));
1142     assert(shouldThrow !is null, "Invalid entry should throw");
1143     assert(shouldThrow.lineNumber == 4);
1144     
1145     contents = 
1146 `#Comment
1147 NotComment
1148 [Group]
1149 Valid=Key`;
1150     shouldThrow = collectException!IniLikeReadException(new IniLikeFile(iniLikeStringReader(contents)));
1151     assert(shouldThrow !is null, "Invalid comment should throw");
1152     assert(shouldThrow.lineNumber == 2);
1153 }