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 /**
21  * Line in group.
22  */
23 struct IniLikeLine
24 {
25     /**
26      * Type of line.
27      */
28     enum Type
29     {
30         None = 0,
31         Comment = 1,
32         KeyValue = 2
33     }
34     
35     /**
36      * Contruct from comment.
37      */
38     @nogc @safe static IniLikeLine fromComment(string comment) nothrow {
39         return IniLikeLine(comment, null, Type.Comment);
40     }
41     
42     /**
43      * Construct from key and value.
44      */
45     @nogc @safe static IniLikeLine fromKeyValue(string key, string value) nothrow {
46         return IniLikeLine(key, value, Type.KeyValue);
47     }
48     
49     /**
50      * Get comment.
51      * Returns: Comment or empty string if type is not Type.Comment.
52      */
53     @nogc @safe string comment() const nothrow {
54         return _type == Type.Comment ? _first : null;
55     }
56     
57     /**
58      * Get key.
59      * Returns: Key or empty string if type is not Type.KeyValue
60      */
61     @nogc @safe string key() const nothrow {
62         return _type == Type.KeyValue ? _first : null;
63     }
64     
65     /**
66      * Get value.
67      * Returns: Value or empty string if type is not Type.KeyValue
68      */
69     @nogc @safe string value() const nothrow {
70         return _type == Type.KeyValue ? _second : null;
71     }
72     
73     /**
74      * Get type of line.
75      */
76     @nogc @safe Type type() const nothrow {
77         return _type;
78     }
79     
80     /**
81      * Assign Type.None to line.
82      */
83     @nogc @safe void makeNone() nothrow {
84         _type = Type.None;
85     }
86 private:
87     string _first;
88     string _second;
89     Type _type = Type.None;
90 }
91 
92 
93 /**
94  * This class represents the group (section) in the ini-like file. 
95  * You can create and use instances of this class only in the context of $(B IniLikeFile) or its derivatives.
96  * Note: Keys are case-sensitive.
97  */
98 class IniLikeGroup
99 {
100 public:
101     /**
102      * Create instange on IniLikeGroup and set its name to groupName.
103      */
104     protected @nogc @safe this(string groupName) nothrow {
105         _name = groupName;
106     }
107     
108     /**
109      * Returns: The value associated with the key
110      * Note: It's an error to access nonexistent value
111      * See_Also: value
112      */
113     @nogc @safe final string opIndex(string key) const nothrow {
114         auto i = key in _indices;
115         assert(_values[*i].type == IniLikeLine.Type.KeyValue);
116         assert(_values[*i].key == key);
117         return _values[*i].value;
118     }
119     
120     /**
121      * Insert new value or replaces the old one if value associated with key already exists.
122      * Returns: Inserted/updated value or null string if key was not added.
123      * Throws: $(B Exception) if key is not valid
124      */
125     @safe final string opIndexAssign(string value, string key) {
126         validateKeyValue(key, value);
127         auto pick = key in _indices;
128         if (pick) {
129             return (_values[*pick] = IniLikeLine.fromKeyValue(key, value)).value;
130         } else {
131             _indices[key] = _values.length;
132             _values ~= IniLikeLine.fromKeyValue(key, value);
133             return value;
134         }
135     }
136     /**
137      * Ditto, localized version.
138      * See_Also: setLocalizedValue, localizedValue
139      */
140     @safe final string opIndexAssign(string value, string key, string locale) {
141         string keyName = localizedKey(key, locale);
142         return this[keyName] = value;
143     }
144     
145     /**
146      * Tell if group contains value associated with the key.
147      */
148     @nogc @safe final bool contains(string key) const nothrow {
149         return value(key) !is null;
150     }
151     
152     /**
153      * Get value by key.
154      * Returns: The value associated with the key, or defaultValue if group does not contain such item.
155      */
156     @nogc @safe final string value(string key, string defaultValue = null) const nothrow {
157         auto pick = key in _indices;
158         if (pick) {
159             if(_values[*pick].type == IniLikeLine.Type.KeyValue) {
160                 assert(_values[*pick].key == key);
161                 return _values[*pick].value;
162             }
163         }
164         return defaultValue;
165     }
166     
167     /**
168      * Perform locale matching lookup as described in $(LINK2 http://standards.freedesktop.org/desktop-entry-spec/latest/ar01s04.html, Localized values for keys).
169      * Returns: The localized value associated with key and locale, or defaultValue if group does not contain item with this key.
170      */
171     @safe final string localizedValue(string key, string locale, string defaultValue = null) const nothrow {
172         //Any ideas how to get rid of this boilerplate and make less allocations?
173         const t = parseLocaleName(locale);
174         auto lang = t.lang;
175         auto country = t.country;
176         auto modifier = t.modifier;
177         
178         if (lang.length) {
179             string pick;
180             if (country.length && modifier.length) {
181                 pick = value(localizedKey(key, locale));
182                 if (pick !is null) {
183                     return pick;
184                 }
185             }
186             if (country.length) {
187                 pick = value(localizedKey(key, lang, country));
188                 if (pick !is null) {
189                     return pick;
190                 }
191             }
192             if (modifier.length) {
193                 pick = value(localizedKey(key, lang, string.init, modifier));
194                 if (pick !is null) {
195                     return pick;
196                 }
197             }
198             pick = value(localizedKey(key, lang, string.init));
199             if (pick !is null) {
200                 return pick;
201             }
202         }
203         
204         return value(key, defaultValue);
205     }
206     
207     ///
208     unittest 
209     {
210         auto lilf = new IniLikeFile;
211         lilf.addGroup("Entry");
212         auto group = lilf.group("Entry");
213         assert(group.name == "Entry"); 
214         group["Name"] = "Programmer";
215         group["Name[ru_RU]"] = "Разработчик";
216         group["Name[ru@jargon]"] = "Кодер";
217         group["Name[ru]"] = "Программист";
218         group["Name[de_DE@dialect]"] = "Programmierer"; //just example
219         group["Name[fr_FR]"] = "Programmeur";
220         group["GenericName"] = "Program";
221         group["GenericName[ru]"] = "Программа";
222         assert(group["Name"] == "Programmer");
223         assert(group.localizedValue("Name", "ru@jargon") == "Кодер");
224         assert(group.localizedValue("Name", "ru_RU@jargon") == "Разработчик");
225         assert(group.localizedValue("Name", "ru") == "Программист");
226         assert(group.localizedValue("Name", "ru_RU.UTF-8") == "Разработчик");
227         assert(group.localizedValue("Name", "nonexistent locale") == "Programmer");
228         assert(group.localizedValue("Name", "de_DE@dialect") == "Programmierer");
229         assert(group.localizedValue("Name", "fr_FR.UTF-8") == "Programmeur");
230         assert(group.localizedValue("GenericName", "ru_RU") == "Программа");
231     }
232     
233     /**
234      * Same as localized version of opIndexAssign, but uses function syntax.
235      */
236     @safe final void setLocalizedValue(string key, string locale, string value) {
237         this[key, locale] = value;
238     }
239     
240     /**
241      * Removes entry by key. To remove localized values use localizedKey.
242      * See_Also: inilike.common.localizedKey
243      */
244     @safe final void removeEntry(string key) nothrow {
245         auto pick = key in _indices;
246         if (pick) {
247             _values[*pick].makeNone();
248         }
249     }
250     
251     /**
252      * Remove all entries satisying ToDelete function. 
253      * ToDelete should be function accepting string key and value and return boolean.
254      */
255     @trusted final void removeEntries(alias ToDelete)()
256     {
257         IniLikeLine[] values;
258         
259         foreach(line; _values) {
260             if (line.type == IniLikeLine.Type.KeyValue && ToDelete(line.key, line.value)) {
261                 _indices.remove(line.key);
262                 continue;
263             }
264             if (line.type == IniLikeLine.Type.None) {
265                 continue;
266             }
267             values ~= line;
268         }
269         
270         _values = values;
271         foreach(i, line; _values) {
272             if (line.type == IniLikeLine.Type.KeyValue) {
273                 _indices[line.key] = i;
274             }
275         }
276     }
277     
278     unittest
279     {
280         string contents = 
281 `[Group]
282 Key1=Value1
283 Name=Value
284 # Comment
285 ToRemove=Value
286 Key2=Value2
287 NameGeneric=Value
288 Key3=Value3`;
289         auto ilf = new IniLikeFile(iniLikeStringReader(contents));
290         ilf.group("Group").removeEntry("ToRemove");
291         ilf.group("Group").removeEntries!(function bool(string key, string value) {
292             return key.startsWith("Name");
293         })();
294         
295         auto group = ilf.group("Group");
296         
297         assert(group.value("Key1") == "Value1");
298         assert(group.value("Key2") == "Value2");
299         assert(group.value("Key3") == "Value3");
300         assert(equal(group.byIniLine(), [
301                     IniLikeLine.fromKeyValue("Key1", "Value1"), IniLikeLine.fromComment("# Comment"), 
302                     IniLikeLine.fromKeyValue("Key2", "Value2"), IniLikeLine.fromKeyValue("Key3", "Value3")]));
303         assert(!group.contains("Name"));
304         assert(!group.contains("NameGeneric"));
305     }
306     
307     /**
308      * Iterate by Key-Value pairs.
309      * Returns: Range of Tuple!(string, "key", string, "value").
310      * See_Also: value, localizedValue
311      */
312     @nogc @safe final auto byKeyValue() const nothrow {
313         return staticByKeyValue(_values);
314     }
315     
316     /**
317      * Empty range of the same type as byKeyValue. Can be used in derived classes if it's needed to have empty range.
318      * Returns: Empty range of Tuple!(string, "key", string, "value").
319      */
320     @nogc @safe static auto emptyByKeyValue() nothrow {
321         return staticByKeyValue((IniLikeLine[]).init);
322     }
323     
324     ///
325     unittest
326     {
327         assert(emptyByKeyValue().empty);
328         auto group = new IniLikeGroup("Group name");
329         static assert(is(typeof(emptyByKeyValue()) == typeof(group.byKeyValue()) ));
330     }
331     
332     private @nogc @safe static auto staticByKeyValue(const(IniLikeLine)[] values) nothrow {
333         return values.filter!(v => v.type == IniLikeLine.Type.KeyValue).map!(v => keyValueTuple(v.key, v.value));
334     }
335     
336     /**
337      * Get name of this group.
338      * Returns: The name of this group.
339      */
340     @nogc @safe final string name() const nothrow {
341         return _name;
342     }
343     
344     /**
345      * Returns: Range of $(B IniLikeLine)s included in this group.
346      */
347     @system final auto byIniLine() const {
348         return _values.filter!(v => v.type != IniLikeLine.Type.None);
349     }
350     
351     /**
352      * Add comment line into the group.
353      * See_Also: byIniLine
354      */
355     @trusted final void addComment(string comment) nothrow {
356         _values ~= IniLikeLine.fromComment(comment);
357     }
358     
359 protected:
360     /**
361      * Validate key and value before setting value to key for this group and throw exception if not valid.
362      * Can be reimplemented in derived classes. 
363      * Default implementation check if key is not empty string, leaving value unchecked.
364      */
365     @trusted void validateKeyValue(string key, string value) const {
366         enforce(key.length > 0, "key must not be empty");
367     }
368     
369 private:
370     size_t[string] _indices;
371     IniLikeLine[] _values;
372     string _name;
373 }
374 
375 /**
376  * Exception thrown on the file read error.
377  */
378 class IniLikeException : Exception
379 {
380     /**
381      * Create IniLikeException with msg, lineNumber and fileName.
382      */
383     this(string msg, size_t lineNumber, string fileName = null, string file = __FILE__, size_t line = __LINE__, Throwable next = null) pure nothrow @safe {
384         super(msg, file, line, next);
385         _lineNumber = lineNumber;
386         _fileName = fileName;
387     }
388     
389     /** 
390      * Number of line in the file where the exception occured, starting from 1.
391      * 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).
392      * Don't confuse with $(B line) property of $(B Throwable).
393      */
394     @nogc @safe size_t lineNumber() const nothrow pure {
395         return _lineNumber;
396     }
397     
398     /**
399      * Number of line in the file where the exception occured, starting from 0. 
400      * Don't confuse with $(B line) property of $(B Throwable).
401      */
402     @nogc @safe size_t lineIndex() const nothrow pure {
403         return _lineNumber ? _lineNumber - 1 : 0;
404     }
405     
406     /**
407      * Name of ini-like file where error occured. 
408      * Can be empty if fileName was not given upon IniLikeFile creating.
409      * Don't confuse with $(B file) property of $(B Throwable).
410      */
411     @nogc @safe string fileName() const nothrow pure {
412         return _fileName;
413     }
414     
415 private:
416     size_t _lineNumber;
417     string _fileName;
418 }
419 
420 /**
421  * Ini-like file.
422  * 
423  */
424 class IniLikeFile
425 {
426 protected:
427     /**
428      * Add comment for group.
429      * This function is called only in constructor and can be reimplemented in derived classes.
430      * Params:
431      *  comment = Comment line to add.
432      *  currentGroup = The group returned recently by createGroup during parsing. Can be null (e.g. if discarded)
433      *  groupName = The name of the currently parsed group. Set even if currentGroup is null.
434      * See_Also: createGroup
435      */
436     @trusted void addCommentForGroup(string comment, IniLikeGroup currentGroup, string groupName)
437     {
438         if (currentGroup) {
439             currentGroup.addComment(comment);
440         }
441     }
442     
443     /**
444      * Add key/value pair for group.
445      * This function is called only in constructor and can be reimplemented in derived classes.
446      * Params:
447      *  key = Key to insert or set.
448      *  value = Value to set for key.
449      *  currentGroup = The group returned recently by createGroup during parsing. Can be null (e.g. if discarded)
450      *  groupName = The name of the currently parsed group. Set even if currentGroup is null.
451      * See_Also: createGroup
452      */
453     @trusted void addKeyValueForGroup(string key, string value, IniLikeGroup currentGroup, string groupName)
454     {
455         if (currentGroup) {
456             if (currentGroup.contains(key)) {
457                 throw new Exception("key already exists");
458             }
459             currentGroup[key] = value;
460         }
461     }
462     
463     /**
464      * Create iniLikeGroup by groupName.
465      * This function is called only in constructor and can be reimplemented in derived classes, 
466      * e.g. to insert additional checks or create specific derived class depending on groupName.
467      * Returned value is later passed to addCommentForGroup and addKeyValueForGroup methods as currentGroup. 
468      * Reimplemented method also is allowd to return null.
469      * Default implementation just returns empty IniLikeGroup with name set to groupName.
470      * Throws:
471      *  $(B Exception) if group with such name already exists.
472      * See_Also:
473      *  addKeyValueForGroup, addCommentForGroup
474      */
475     @trusted IniLikeGroup createGroup(string groupName)
476     {
477         enforce(group(groupName) is null, "group already exists");
478         return createEmptyGroup(groupName);
479     }
480     
481     /**
482      * Can be used in derived classes to create instance of IniLikeGroup.
483      */
484     @safe static createEmptyGroup(string groupName) {
485         return new IniLikeGroup(groupName);
486     }
487     
488 public:
489     /**
490      * Construct empty IniLikeFile, i.e. without any groups or values
491      */
492     @nogc @safe this() nothrow {
493         
494     }
495     
496     /**
497      * Read from file.
498      * Throws:
499      *  $(B ErrnoException) if file could not be opened.
500      *  $(B IniLikeException) if error occured while reading the file.
501      */
502     @safe this(string fileName) {
503         this(iniLikeFileReader(fileName), fileName);
504     }
505     
506     /**
507      * Read from range of $(B IniLikeLine)s.
508      * Note: All exceptions thrown within constructor are turning into IniLikeException.
509      * Throws:
510      *  $(B IniLikeException) if error occured while parsing.
511      */
512     @trusted this(IniLikeReader)(IniLikeReader reader, string fileName = null)
513     {
514         size_t lineNumber = 0;
515         IniLikeGroup currentGroup;
516         
517         version(DigitalMars) {
518             static void foo(size_t ) {}
519         }
520         
521         try {
522             foreach(line; reader.byFirstLines)
523             {
524                 lineNumber++;
525                 if (line.isComment || line.strip.empty) {
526                     addLeadingComment(line);
527                 } else {
528                     throw new Exception("Expected comment or empty line before any group");
529                 }
530             }
531             
532             foreach(g; reader.byGroup)
533             {
534                 lineNumber++;
535                 string groupName = g.name;
536                 
537                 version(DigitalMars) {
538                     foo(lineNumber); //fix dmd codgen bug with -O
539                 }
540                 
541                 currentGroup = addGroup(groupName);
542                 
543                 foreach(line; g.byEntry)
544                 {
545                     lineNumber++;
546                     
547                     if (line.isComment || line.strip.empty) {
548                         addCommentForGroup(line, currentGroup, groupName);
549                     } else {
550                         const t = parseKeyValue(line);
551                         
552                         string key = t.key.stripRight;
553                         string value = t.value.stripLeft;
554                         
555                         if (key.length == 0 && value.length == 0) {
556                             throw new Exception("Expected comment, empty line or key value inside group");
557                         } else {
558                             addKeyValueForGroup(key, value, currentGroup, groupName);
559                         }
560                     }
561                 }
562             }
563             
564             _fileName = fileName;
565             
566         }
567         catch (Exception e) {
568             throw new IniLikeException(e.msg, lineNumber, fileName, e.file, e.line, e.next);
569         }
570     }
571     
572     /**
573      * Get group by name.
574      * Returns: IniLikeGroup instance associated with groupName or $(B null) if not found.
575      * See_Also: byGroup
576      */
577     @nogc @safe final inout(IniLikeGroup) group(string groupName) nothrow inout {
578         auto pick = groupName in _groupIndices;
579         if (pick) {
580             return _groups[*pick];
581         }
582         return null;
583     }
584     
585     /**
586      * Create new group using groupName.
587      * Returns: Newly created instance of IniLikeGroup.
588      * Throws: Exception if group with such name already exists or groupName is empty.
589      * See_Also: removeGroup, group
590      */
591     @safe final IniLikeGroup addGroup(string groupName) {
592         enforce(groupName.length, "empty group name");
593         
594         auto iniLikeGroup = createGroup(groupName);
595         if (iniLikeGroup !is null) {
596             _groupIndices[groupName] = _groups.length;
597             _groups ~= iniLikeGroup;
598         }
599         return iniLikeGroup;
600     }
601     
602     /**
603      * Remove group by name.
604      * See_Also: addGroup, group
605      */
606     @safe void removeGroup(string groupName) nothrow {
607         auto pick = groupName in _groupIndices;
608         if (pick) {
609             _groups[*pick] = null;
610         }
611     }
612     
613     /**
614      * Range of groups in order how they were defined in file.
615      * See_Also: group
616      */
617     @nogc @safe final auto byGroup() const nothrow {
618         return _groups.filter!(f => f !is null);
619     }
620     
621     ///ditto
622     @nogc @safe final auto byGroup() nothrow {
623         return _groups.filter!(f => f !is null);
624     }
625     
626     
627     /**
628      * Save object to the file using .ini-like format.
629      * Throws: ErrnoException if the file could not be opened or an error writing to the file occured.
630      * See_Also: saveToString, save
631      */
632     @trusted final void saveToFile(string fileName) const {
633         import std.stdio : File;
634         
635         auto f = File(fileName, "w");
636         void dg(in string line) {
637             f.writeln(line);
638         }
639         save(&dg);
640     }
641     
642     /**
643      * Save object to string using .ini like format.
644      * Returns: A string that represents the contents of file.
645      * See_Also: saveToFile, save
646      */
647     @safe final string saveToString() const {
648         auto a = appender!(string[])();
649         save(a);
650         return a.data.join("\n");
651     }
652     
653     /**
654      * Use Output range or delegate to retrieve strings line by line. 
655      * Those strings can be written to the file or be showed in text area.
656      * Note: returned strings don't have trailing newline character.
657      */
658     @trusted final void save(OutRange)(OutRange sink) const if (isOutputRange!(OutRange, string)) {
659         foreach(line; leadingComments()) {
660             put(sink, line);
661         }
662         
663         foreach(group; byGroup()) {
664             put(sink, "[" ~ group.name ~ "]");
665             foreach(line; group.byIniLine()) {
666                 if (line.type == IniLikeLine.Type.Comment) {
667                     put(sink, line.comment);
668                 } else if (line.type == IniLikeLine.Type.KeyValue) {
669                     put(sink, line.key ~ "=" ~ line.value);
670                 }
671             }
672         }
673     }
674     
675     /**
676      * File path where the object was loaded from.
677      * Returns: File name as was specified on the object creation.
678      */
679     @nogc @safe final string fileName() nothrow const {
680         return _fileName;
681     }
682     
683     @nogc @trusted final auto leadingComments() const nothrow {
684         return _leadingComments;
685     }
686     
687     @trusted void addLeadingComment(string line) nothrow {
688         _leadingComments ~= line;
689     }
690     
691 private:
692     string _fileName;
693     size_t[string] _groupIndices;
694     IniLikeGroup[] _groups;
695     string[] _leadingComments;
696 }
697 
698 ///
699 unittest
700 {
701     import std.file;
702     import std.path;
703     
704     string contents = 
705 `# The first comment
706 [First Entry]
707 # Comment
708 GenericName=File manager
709 GenericName[ru]=Файловый менеджер
710 # Another comment
711 [Another Group]
712 Name=Commander
713 Comment=Manage files
714 # The last comment`;
715 
716     auto ilf = new IniLikeFile(iniLikeStringReader(contents), "contents.ini");
717     assert(ilf.fileName() == "contents.ini");
718     assert(equal(ilf.leadingComments(), ["# The first comment"]));
719     assert(ilf.group("First Entry"));
720     assert(ilf.group("Another Group"));
721     assert(ilf.saveToString() == contents);
722     
723     string tempFile = buildPath(tempDir(), "inilike-unittest-tempfile");
724     try {
725         assertNotThrown!IniLikeException(ilf.saveToFile(tempFile));
726         auto fileContents = cast(string)std.file.read(tempFile);
727         static if( __VERSION__ < 2067 ) {
728             assert(equal(fileContents.splitLines, contents.splitLines), "Contents should be preserved as is");
729         } else {
730             assert(equal(fileContents.lineSplitter, contents.lineSplitter), "Contents should be preserved as is");
731         }
732         
733         IniLikeFile filf; 
734         assertNotThrown!IniLikeException(filf = new IniLikeFile(tempFile));
735         assert(filf.fileName() == tempFile);
736         remove(tempFile);
737     } catch(Exception e) {
738         //environmental error in unittests
739     }
740     
741     auto firstEntry = ilf.group("First Entry");
742     
743     assert(!firstEntry.contains("NonExistent"));
744     assert(firstEntry.contains("GenericName"));
745     assert(firstEntry.contains("GenericName[ru]"));
746     assert(firstEntry["GenericName"] == "File manager");
747     assert(firstEntry.value("GenericName") == "File manager");
748     firstEntry["GenericName"] = "Manager of files";
749     assert(firstEntry["GenericName"] == "Manager of files");
750     firstEntry["Authors"] = "Unknown";
751     assert(firstEntry["Authors"] == "Unknown");
752     
753     assert(firstEntry.localizedValue("GenericName", "ru") == "Файловый менеджер");
754     firstEntry.setLocalizedValue("GenericName", "ru", "Менеджер файлов");
755     assert(firstEntry.localizedValue("GenericName", "ru") == "Менеджер файлов");
756     firstEntry.setLocalizedValue("Authors", "ru", "Неизвестны");
757     assert(firstEntry.localizedValue("Authors", "ru") == "Неизвестны");
758     
759     firstEntry.removeEntry("GenericName");
760     assert(!firstEntry.contains("GenericName"));
761     firstEntry["GenericName"] = "File Manager";
762     assert(firstEntry["GenericName"] == "File Manager");
763     
764     assert(ilf.group("Another Group")["Name"] == "Commander");
765     assert(equal(ilf.group("Another Group").byKeyValue(), [ keyValueTuple("Name", "Commander"), keyValueTuple("Comment", "Manage files") ]));
766     assert(equal(
767         ilf.group("Another Group").byIniLine(), 
768         [IniLikeLine.fromKeyValue("Name", "Commander"), IniLikeLine.fromKeyValue("Comment", "Manage files"), IniLikeLine.fromComment("# The last comment")]
769     ));
770     
771     assert(equal(ilf.byGroup().map!(g => g.name), ["First Entry", "Another Group"]));
772     
773     ilf.removeGroup("Another Group");
774     assert(!ilf.group("Another Group"));
775     assert(equal(ilf.byGroup().map!(g => g.name), ["First Entry"]));
776     
777     ilf.addGroup("Another Group");
778     assert(ilf.group("Another Group"));
779     assert(ilf.group("Another Group").byIniLine().empty);
780     assert(ilf.group("Another Group").byKeyValue().empty);
781     
782     ilf.addGroup("Other Group");
783     assert(equal(ilf.byGroup().map!(g => g.name), ["First Entry", "Another Group", "Other Group"]));
784     
785     const IniLikeFile cilf = ilf;
786     static assert(is(typeof(cilf.byGroup())));
787     static assert(is(typeof(cilf.group("First Entry").byKeyValue())));
788     static assert(is(typeof(cilf.group("First Entry").byIniLine())));
789     
790     contents = 
791 `[Group]
792 GenericName=File manager
793 [Group]
794 GenericName=Commander`;
795 
796     auto shouldThrow = collectException!IniLikeException(new IniLikeFile(iniLikeStringReader(contents), "config.ini"));
797     assert(shouldThrow !is null, "Duplicate groups should throw");
798     assert(shouldThrow.lineNumber == 3);
799     assert(shouldThrow.lineIndex == 2);
800     assert(shouldThrow.fileName == "config.ini");
801     
802     contents = 
803 `[Group]
804 Key=Value1
805 Key=Value2`;
806 
807     shouldThrow = collectException!IniLikeException(new IniLikeFile(iniLikeStringReader(contents)));
808     assert(shouldThrow !is null, "Duplicate key should throw");
809     assert(shouldThrow.lineNumber == 3);
810     
811     contents =
812 `[Group]
813 Key=Value
814 =File manager`;
815 
816     shouldThrow = collectException!IniLikeException(new IniLikeFile(iniLikeStringReader(contents)));
817     assert(shouldThrow !is null, "Empty key should throw");
818     assert(shouldThrow.lineNumber == 3);
819     
820     contents = 
821 `[Group]
822 #Comment
823 Valid=Key
824 NotKeyNotGroupNotComment`;
825 
826     shouldThrow = collectException!IniLikeException(new IniLikeFile(iniLikeStringReader(contents)));
827     assert(shouldThrow !is null, "Invalid entry should throw");
828     assert(shouldThrow.lineNumber == 4);
829     
830     contents = 
831 `#Comment
832 NotComment
833 [Group]
834 Valid=Key`;
835     shouldThrow = collectException!IniLikeException(new IniLikeFile(iniLikeStringReader(contents)));
836     assert(shouldThrow !is null, "Invalid comment should throw");
837     assert(shouldThrow.lineNumber == 2);
838 }
839