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