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 return iniLikeRangeReader(contents.splitLines()); 592 } 593 594 /** 595 * Ini-like file. 596 * 597 */ 598 class IniLikeFile 599 { 600 public: 601 ///Flags to manage .ini like file reading 602 enum ReadOptions 603 { 604 noOptions = 0, /// Read all groups and skip comments and empty lines. 605 firstGroupOnly = 1, /// Ignore other groups than the first one. 606 preserveComments = 2, /// Preserve comments and empty lines. Use this when you want to keep them across writing. 607 ignoreGroupDuplicates = 4, /// Ignore group duplicates. The first found will be used. 608 ignoreInvalidKeys = 8 /// Skip invalid keys during parsing. 609 } 610 611 /** 612 * Construct empty IniLikeFile, i.e. without any groups or values 613 */ 614 @nogc @safe this() nothrow { 615 616 } 617 618 /** 619 * Read from file. 620 * Throws: 621 * $(B ErrnoException) if file could not be opened. 622 * $(B IniLikeException) if error occured while reading the file. 623 */ 624 @safe this(string fileName, ReadOptions options = ReadOptions.noOptions) { 625 this(iniLikeFileReader(fileName), options, fileName); 626 } 627 628 /** 629 * Read from range of $(B IniLikeLine)s. 630 * Throws: 631 * $(B IniLikeException) if error occured while parsing. 632 */ 633 @trusted this(Range)(Range byLine, ReadOptions options = ReadOptions.noOptions, string fileName = null) if(is(ElementType!Range : IniLikeLine)) 634 { 635 size_t lineNumber = 0; 636 IniLikeGroup currentGroup; 637 bool ignoreKeyValues; 638 639 try { 640 foreach(line; byLine) 641 { 642 lineNumber++; 643 final switch(line.type) 644 { 645 case IniLikeLine.Type.Comment: 646 { 647 if (options & ReadOptions.preserveComments) { 648 if (currentGroup is null) { 649 addFirstComment(line.comment); 650 } else { 651 currentGroup.addComment(line.comment); 652 } 653 } 654 } 655 break; 656 case IniLikeLine.Type.GroupStart: 657 { 658 if (currentGroup !is null && (options & ReadOptions.firstGroupOnly)) { 659 break; 660 } 661 662 if ((options & ReadOptions.ignoreGroupDuplicates) && group(line.groupName)) { 663 ignoreKeyValues = true; 664 continue; 665 } 666 ignoreKeyValues = false; 667 currentGroup = addGroup(line.groupName); 668 } 669 break; 670 case IniLikeLine.Type.KeyValue: 671 { 672 if (ignoreKeyValues || ((options & ReadOptions.ignoreInvalidKeys) && !isValidKey(line.key)) ) { 673 continue; 674 } 675 enforce(currentGroup, "met key-value pair before any group"); 676 currentGroup[line.key] = line.value; 677 } 678 break; 679 case IniLikeLine.Type.None: 680 { 681 throw new Exception("not key-value pair, nor group start nor comment"); 682 } 683 } 684 } 685 686 _fileName = fileName; 687 } 688 catch (Exception e) { 689 throw new IniLikeException(e.msg, lineNumber, e.file, e.line, e.next); 690 } 691 } 692 693 /** 694 * Get group by name. 695 * Returns: IniLikeGroup instance associated with groupName or $(B null) if not found. 696 * See_Also: byGroup 697 */ 698 @nogc @safe inout(IniLikeGroup) group(string groupName) nothrow inout { 699 auto pick = groupName in _groupIndices; 700 if (pick) { 701 return _groups[*pick]; 702 } 703 return null; 704 } 705 706 /** 707 * Create new group using groupName. 708 * Returns: newly created instance of IniLikeGroup. 709 * Throws: Exception if group with such name already exists or groupName is empty. 710 * See_Also: removeGroup, group 711 */ 712 @safe IniLikeGroup addGroup(string groupName) { 713 enforce(groupName.length, "empty group name"); 714 enforce(group(groupName) is null, "group already exists"); 715 716 auto iniLikeGroup = new IniLikeGroup(groupName, this); 717 _groupIndices[groupName] = _groups.length; 718 _groups ~= iniLikeGroup; 719 720 return iniLikeGroup; 721 } 722 723 /** 724 * Remove group by name. 725 * See_Also: addGroup, group 726 */ 727 @safe void removeGroup(string groupName) nothrow { 728 auto pick = groupName in _groupIndices; 729 if (pick) { 730 _groups[*pick] = null; 731 } 732 } 733 734 /** 735 * Range of groups in order how they were defined in file. 736 * See_Also: group 737 */ 738 @nogc @safe final auto byGroup() const nothrow { 739 return _groups.filter!(f => f !is null); 740 } 741 742 ///ditto 743 @nogc @safe final auto byGroup() nothrow { 744 return _groups.filter!(f => f !is null); 745 } 746 747 748 /** 749 * Save object to the file using .ini-like format. 750 * Throws: ErrnoException if the file could not be opened or an error writing to the file occured. 751 * See_Also: saveToString, save 752 */ 753 @trusted void saveToFile(string fileName) const { 754 auto f = File(fileName, "w"); 755 void dg(string line) { 756 f.writeln(line); 757 } 758 save(&dg); 759 } 760 761 /** 762 * Save object to string using .ini like format. 763 * Returns: string representing the contents of file. 764 * See_Also: saveToFile, save 765 */ 766 @safe string saveToString() const { 767 auto a = appender!(string[])(); 768 void dg(string line) { 769 a.put(line); 770 } 771 save(&dg); 772 return a.data.join("\n"); 773 } 774 775 /** 776 * Alias for saving delegate. 777 * See_Also: save 778 */ 779 alias SaveDelegate = void delegate(string); 780 781 /** 782 * Use delegate to retrieve strings line by line. 783 * Those strings can be written to the file or be showed in text area. 784 * Note: returned strings don't have trailing newline character. 785 */ 786 @trusted void save(SaveDelegate sink) const { 787 foreach(line; firstComments()) { 788 sink(line); 789 } 790 791 foreach(group; byGroup()) { 792 sink("[" ~ group.name ~ "]"); 793 foreach(line; group.byIniLine()) { 794 if (line.type == IniLikeLine.Type.Comment) { 795 sink(line.comment); 796 } else if (line.type == IniLikeLine.Type.KeyValue) { 797 sink(line.key ~ "=" ~ line.value); 798 } 799 } 800 } 801 } 802 803 /** 804 * File path where the object was loaded from. 805 * Returns: file name as was specified on the object creation. 806 */ 807 @nogc @safe string fileName() nothrow const { 808 return _fileName; 809 } 810 811 /** 812 * Tell whether the string is valid key. For IniLikeFile the valid key is any non-empty string. 813 * Reimplement this function in the derived class to throw exception from IniLikeGroup when key is invalid. 814 */ 815 @nogc @safe bool isValidKey(string key) pure nothrow const { 816 return key.length != 0; 817 } 818 819 protected: 820 @nogc @trusted final auto firstComments() const nothrow { 821 return _firstComments; 822 } 823 824 @trusted final void addFirstComment(string line) nothrow { 825 _firstComments ~= line; 826 } 827 828 private: 829 string _fileName; 830 size_t[string] _groupIndices; 831 IniLikeGroup[] _groups; 832 string[] _firstComments; 833 } 834 835 /// 836 unittest 837 { 838 string contents = 839 `# The first comment 840 [First Entry] 841 # Comment 842 GenericName=File manager 843 GenericName[ru]=Файловый менеджер 844 # Another comment 845 [Another Group] 846 Name=Commander 847 Comment=Manage files 848 # The last comment`; 849 850 auto ilf = new IniLikeFile(iniLikeStringReader(contents), IniLikeFile.ReadOptions.preserveComments, "contents.ini"); 851 assert(ilf.fileName() == "contents.ini"); 852 assert(ilf.group("First Entry")); 853 assert(ilf.group("Another Group")); 854 assert(ilf.saveToString() == contents); 855 856 auto firstEntry = ilf.group("First Entry"); 857 858 assert(firstEntry["GenericName"] == "File manager"); 859 assert(firstEntry.value("GenericName") == "File manager"); 860 firstEntry["GenericName"] = "Manager of files"; 861 assert(firstEntry["GenericName"] == "Manager of files"); 862 firstEntry["Authors"] = "Unknown"; 863 assert(firstEntry["Authors"] == "Unknown"); 864 865 assert(firstEntry.localizedValue("GenericName", "ru") == "Файловый менеджер"); 866 firstEntry.setLocalizedValue("GenericName", "ru", "Менеджер файлов"); 867 assert(firstEntry.localizedValue("GenericName", "ru") == "Менеджер файлов"); 868 firstEntry.setLocalizedValue("Authors", "ru", "Неизвестны"); 869 assert(firstEntry.localizedValue("Authors", "ru") == "Неизвестны"); 870 871 firstEntry.removeEntry("GenericName"); 872 assert(!firstEntry.contains("GenericName")); 873 firstEntry["GenericName"] = "File Manager"; 874 assert(firstEntry["GenericName"] == "File Manager"); 875 876 assert(ilf.group("Another Group")["Name"] == "Commander"); 877 assert(equal(ilf.group("Another Group").byKeyValue(), [ KeyValueTuple("Name", "Commander"), KeyValueTuple("Comment", "Manage files") ])); 878 879 assert(equal(ilf.byGroup().map!(g => g.name), ["First Entry", "Another Group"])); 880 881 ilf.removeGroup("Another Group"); 882 assert(!ilf.group("Another Group")); 883 assert(equal(ilf.byGroup().map!(g => g.name), ["First Entry"])); 884 885 ilf.addGroup("Another Group"); 886 assert(ilf.group("Another Group")); 887 assert(ilf.group("Another Group").byIniLine().empty); 888 assert(ilf.group("Another Group").byKeyValue().empty); 889 890 ilf.addGroup("Other Group"); 891 assert(equal(ilf.byGroup().map!(g => g.name), ["First Entry", "Another Group", "Other Group"])); 892 893 const IniLikeFile cilf = ilf; 894 static assert(is(typeof(cilf.byGroup()))); 895 static assert(is(typeof(cilf.group("First Entry").byKeyValue()))); 896 static assert(is(typeof(cilf.group("First Entry").byIniLine()))); 897 898 contents = 899 `[First] 900 Key=Value 901 [Second] 902 Key=Value`; 903 ilf = new IniLikeFile(iniLikeStringReader(contents), IniLikeFile.ReadOptions.firstGroupOnly); 904 assert(ilf.group("First") !is null); 905 assert(ilf.group("Second") is null); 906 assert(ilf.group("First")["Key"] == "Value"); 907 908 contents = 909 `[Group] 910 GenericName=File manager 911 [Group] 912 Name=Commander`; 913 914 assertThrown(new IniLikeFile(iniLikeStringReader(contents))); 915 assertNotThrown(new IniLikeFile(iniLikeStringReader(contents), IniLikeFile.ReadOptions.ignoreGroupDuplicates)); 916 917 contents = 918 `[Group] 919 =File manager`; 920 921 assertThrown(new IniLikeFile(iniLikeStringReader(contents))); 922 assertNotThrown(new IniLikeFile(iniLikeStringReader(contents), IniLikeFile.ReadOptions.ignoreInvalidKeys)); 923 }