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 }