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