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