1 module inilike; 2 3 private { 4 import std.algorithm; 5 import std.array; 6 import std.conv; 7 import std.exception; 8 import std.file; 9 import std.path; 10 import std.process; 11 import std.range; 12 import std.stdio; 13 import std.string; 14 import std.traits; 15 import std.typecons; 16 } 17 18 private alias LocaleTuple = Tuple!(string, "lang", string, "country", string, "encoding", string, "modifier"); 19 private alias KeyValueTuple = Tuple!(string, "key", string, "value"); 20 21 /** Retrieves current locale probing environment variables LC_TYPE, LC_ALL and LANG (in this order) 22 * Returns: locale in posix form or empty string if could not determine locale 23 */ 24 string currentLocale() @safe nothrow 25 { 26 static string cache; 27 if (cache is null) { 28 try { 29 cache = environment.get("LC_CTYPE", environment.get("LC_ALL", environment.get("LANG"))); 30 } 31 catch(Exception e) { 32 33 } 34 if (cache is null) { 35 cache = ""; 36 } 37 } 38 return cache; 39 } 40 41 /** 42 * Makes locale name based on language, country, encoding and modifier. 43 * Returns: locale name in form lang_COUNTRY.ENCODING@MODIFIER 44 */ 45 string makeLocaleName(string lang, string country = null, string encoding = null, string modifier = null) pure nothrow @safe 46 { 47 return lang ~ (country.length ? "_"~country : "") ~ (encoding.length ? "."~encoding : "") ~ (modifier.length ? "@"~modifier : ""); 48 } 49 50 /** 51 * Parses 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 */ 54 auto parseLocaleName(string locale) pure nothrow @nogc @trusted 55 { 56 auto modifiderSplit = findSplit(locale, "@"); 57 auto modifier = modifiderSplit[2]; 58 59 auto encodongSplit = findSplit(modifiderSplit[0], "."); 60 auto encoding = encodongSplit[2]; 61 62 auto countrySplit = findSplit(encodongSplit[0], "_"); 63 auto country = countrySplit[2]; 64 65 auto lang = countrySplit[0]; 66 67 return LocaleTuple(lang, country, encoding, modifier); 68 } 69 70 /** 71 * Constructs localized key name from key and locale. 72 * Returns: localized key in form key[locale]. Automatically omits locale encoding if present. 73 */ 74 string localizedKey(string key, string locale) pure nothrow @safe 75 { 76 auto t = parseLocaleName(locale); 77 if (!t.encoding.empty) { 78 locale = makeLocaleName(t.lang, t.country, null, t.modifier); 79 } 80 return key ~ "[" ~ locale ~ "]"; 81 } 82 83 /** 84 * Ditto, but constructs locale name from arguments. 85 */ 86 string localizedKey(string key, string lang, string country, string modifier = null) pure nothrow @safe 87 { 88 return key ~ "[" ~ makeLocaleName(lang, country, null, modifier) ~ "]"; 89 } 90 91 /** 92 * Separates key name into non-localized key and locale name. 93 * If key is not localized returns original key and empty string. 94 * Returns: tuple of key and locale name; 95 */ 96 Tuple!(string, string) separateFromLocale(string key) nothrow @nogc @trusted { 97 if (key.endsWith("]")) { 98 auto t = key.findSplit("["); 99 if (t[1].length) { 100 return tuple(t[0], t[2][0..$-1]); 101 } 102 } 103 return tuple(key, string.init); 104 } 105 106 /** 107 * Tells whether the character is valid for desktop entry key. 108 * Note: This does not include characters presented in locale names. 109 */ 110 bool isValidKeyChar(char c) pure nothrow @nogc @safe 111 { 112 return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-'; 113 } 114 115 116 /** 117 * Tells whethe the string is valid dekstop entry key. 118 * Note: This does not include characters presented in locale names. Use $(B separateFromLocale) to get non-localized key to pass it to this function 119 */ 120 bool isValidKey(string key) pure nothrow @nogc @safe 121 { 122 if (key.empty) { 123 return false; 124 } 125 for (size_t i = 0; i<key.length; ++i) { 126 if (!key[i].isValidKeyChar()) { 127 return false; 128 } 129 } 130 return true; 131 } 132 133 /** 134 * Tells whether the dekstop entry value presents true 135 */ 136 bool isTrue(string value) pure nothrow @nogc @safe { 137 return (value == "true" || value == "1"); 138 } 139 140 /** 141 * Tells whether the desktop entry value presents false 142 */ 143 bool isFalse(string value) pure nothrow @nogc @safe { 144 return (value == "false" || value == "0"); 145 } 146 147 /** 148 * Check if the desktop entry value can be interpreted as boolean value. 149 */ 150 bool isBoolean(string value) pure nothrow @nogc @safe { 151 return isTrue(value) || isFalse(value); 152 } 153 /** 154 * Escapes string by replacing special symbols with escaped sequences. 155 * These symbols are: '\\' (backslash), '\n' (newline), '\r' (carriage return) and '\t' (tab). 156 * Note: 157 * Currently the library stores values as they were loaded from file, i.e. escaped. 158 * To keep things consistent you should take care about escaping the value before inserting. The library will not do it for you. 159 * Returns: Escaped string. 160 * Example: 161 ---- 162 assert("\\next\nline".escapeValue() == `\\next\nline`); // notice how the string on the right is raw. 163 ---- 164 */ 165 string escapeValue(string value) @trusted nothrow pure { 166 return value.replace("\\", `\\`).replace("\n", `\n`).replace("\r", `\r`).replace("\t", `\t`); 167 } 168 169 string doUnescape(string value, in Tuple!(char, char)[] pairs) @trusted nothrow pure { 170 auto toReturn = appender!string(); 171 172 for (size_t i = 0; i < value.length; i++) { 173 if (value[i] == '\\') { 174 if (i < value.length - 1) { 175 char c = value[i+1]; 176 auto t = pairs.find!"a[0] == b[0]"(tuple(c,c)); 177 if (!t.empty) { 178 toReturn.put(t.front[1]); 179 i++; 180 continue; 181 } 182 } 183 } 184 toReturn.put(value[i]); 185 } 186 return toReturn.data; 187 } 188 189 190 /** 191 * 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). 192 * Returns: Unescaped string. 193 * Example: 194 ----- 195 assert(`\\next\nline`.unescapeValue() == "\\next\nline"); // notice how the string on the left is raw. 196 ---- 197 */ 198 string unescapeValue(string value) @trusted nothrow pure 199 { 200 static immutable Tuple!(char, char)[] pairs = [ 201 tuple('s', ' '), 202 tuple('n', '\n'), 203 tuple('r', '\r'), 204 tuple('t', '\t'), 205 tuple('\\', '\\') 206 ]; 207 return doUnescape(value, pairs); 208 } 209 210 string unescapeExec(string str) @trusted nothrow pure 211 { 212 static immutable Tuple!(char, char)[] pairs = [ 213 tuple('"', '"'), 214 tuple('\'', '\''), 215 tuple('\\', '\\'), 216 tuple('>', '>'), 217 tuple('<', '<'), 218 tuple('~', '~'), 219 tuple('|', '|'), 220 tuple('&', '&'), 221 tuple(';', ';'), 222 tuple('$', '$'), 223 tuple('*', '*'), 224 tuple('?', '?'), 225 tuple('#', '#'), 226 tuple('(', '('), 227 tuple(')', ')'), 228 ]; 229 return doUnescape(str, pairs); 230 } 231 232 struct IniLikeLine 233 { 234 enum Type 235 { 236 None = 0, 237 Comment = 1, 238 KeyValue = 2, 239 GroupStart = 4 240 } 241 242 static IniLikeLine fromComment(string comment) @safe { 243 return IniLikeLine(comment, null, Type.Comment); 244 } 245 246 static IniLikeLine fromGroupName(string groupName) @safe { 247 return IniLikeLine(groupName, null, Type.GroupStart); 248 } 249 250 static IniLikeLine fromKeyValue(string key, string value) @safe { 251 return IniLikeLine(key, value, Type.KeyValue); 252 } 253 254 string comment() const @safe @nogc nothrow { 255 return _type == Type.Comment ? _first : null; 256 } 257 258 string key() const @safe @nogc nothrow { 259 return _type == Type.KeyValue ? _first : null; 260 } 261 262 string value() const @safe @nogc nothrow { 263 return _type == Type.KeyValue ? _second : null; 264 } 265 266 string groupName() const @safe @nogc nothrow { 267 return _type == Type.GroupStart ? _first : null; 268 } 269 270 Type type() const @safe @nogc nothrow { 271 return _type; 272 } 273 274 void makeNone() @safe @nogc nothrow { 275 _type = Type.None; 276 } 277 278 private: 279 string _first; 280 string _second; 281 Type _type = Type.None; 282 } 283 284 final class IniLikeGroup 285 { 286 private: 287 this(string name) @safe @nogc nothrow { 288 _name = name; 289 } 290 291 public: 292 293 /** 294 * Returns: the value associated with the key 295 * Note: it's an error to access nonexistent value 296 */ 297 string opIndex(string key) const @safe @nogc nothrow { 298 auto i = key in _indices; 299 assert(_values[*i].type == IniLikeLine.Type.KeyValue); 300 assert(_values[*i].key == key); 301 return _values[*i].value; 302 } 303 304 /** 305 * Inserts new value or replaces the old one if value associated with key already exists. 306 * Returns: inserted/updated value 307 * Throws: $(B Exception) if key is not valid 308 * See_Also: isValidKey 309 */ 310 string opIndexAssign(string value, string key) @safe { 311 enforce(separateFromLocale(key)[0].isValidKey(), "key is invalid"); 312 auto pick = key in _indices; 313 if (pick) { 314 return (_values[*pick] = IniLikeLine.fromKeyValue(key, value)).value; 315 } else { 316 _indices[key] = _values.length; 317 _values ~= IniLikeLine.fromKeyValue(key, value); 318 return value; 319 } 320 } 321 /** 322 * Ditto, but also allows to specify the locale. 323 * See_Also: setLocalizedValue, localizedValue 324 */ 325 string opIndexAssign(string value, string key, string locale) @safe { 326 string keyName = localizedKey(key, locale); 327 return this[keyName] = value; 328 } 329 330 /** 331 * Tells if group contains value associated with the key. 332 */ 333 bool contains(string key) const @safe @nogc nothrow { 334 return value(key) !is null; 335 } 336 337 /** 338 * Returns: the value associated with the key, or defaultValue if group does not contain item with this key. 339 */ 340 string value(string key, string defaultValue = null) const @safe @nogc nothrow { 341 auto pick = key in _indices; 342 if (pick) { 343 if(_values[*pick].type == IniLikeLine.Type.KeyValue) { 344 assert(_values[*pick].key == key); 345 return _values[*pick].value; 346 } 347 } 348 return defaultValue; 349 } 350 351 /** 352 * Performs locale matching lookup as described in $(LINK2 http://standards.freedesktop.org/desktop-entry-spec/latest/ar01s04.html, Localized values for keys). 353 * If locale is null it calls currentLocale to get the locale. 354 * Returns: the localized value associated with key and locale, or defaultValue if group does not contain item with this key. 355 */ 356 string localizedValue(string key, string locale = null, string defaultValue = null) const @safe nothrow { 357 if (locale is null) { 358 locale = currentLocale(); 359 } 360 361 //Any ideas how to get rid of this boilerplate and make less allocations? 362 auto t = parseLocaleName(locale); 363 auto lang = t.lang; 364 auto country = t.country; 365 auto modifier = t.modifier; 366 367 if (lang.length) { 368 string pick; 369 370 if (country.length && modifier.length) { 371 pick = value(localizedKey(key, locale)); 372 if (pick !is null) { 373 return pick; 374 } 375 } 376 377 if (country.length) { 378 pick = value(localizedKey(key, lang, country)); 379 if (pick !is null) { 380 return pick; 381 } 382 } 383 384 if (modifier.length) { 385 pick = value(localizedKey(key, lang, null, modifier)); 386 if (pick !is null) { 387 return pick; 388 } 389 } 390 391 pick = value(localizedKey(key, lang, null)); 392 if (pick !is null) { 393 return pick; 394 } 395 } 396 397 return value(key, defaultValue); 398 } 399 400 /** 401 * Same as localized version of opIndexAssign, but uses function syntax. 402 */ 403 void setLocalizedValue(string key, string locale, string value) @safe { 404 this[key, locale] = value; 405 } 406 407 /** 408 * Removes entry by key. To remove localized values use localizedKey. 409 */ 410 void removeEntry(string key) @safe nothrow { 411 auto pick = key in _indices; 412 if (pick) { 413 _values[*pick].makeNone(); 414 } 415 } 416 417 /** 418 * Returns: range of Tuple!(string, "key", string, "value") 419 */ 420 auto byKeyValue() const @safe @nogc nothrow { 421 return _values.filter!(v => v.type == IniLikeLine.Type.KeyValue).map!(v => KeyValueTuple(v.key, v.value)); 422 } 423 424 /** 425 * Returns: the name of group 426 */ 427 string name() const @safe @nogc nothrow { 428 return _name; 429 } 430 431 auto byLine() const { 432 return _values; 433 } 434 435 void addComment(string comment) { 436 _values ~= IniLikeLine.fromComment(comment); 437 } 438 439 private: 440 size_t[string] _indices; 441 IniLikeLine[] _values; 442 string _name; 443 } 444 445 /** 446 * Exception thrown on the file read error. 447 */ 448 class IniLikeException : Exception 449 { 450 this(string msg, size_t lineNumber, string file = __FILE__, size_t line = __LINE__, Throwable next = null) pure nothrow @safe { 451 super(msg, file, line, next); 452 _lineNumber = lineNumber; 453 } 454 455 ///Number of line in desktop file where the exception occured, starting from 1. Don't be confused with $(B line) property of $(B Throwable). 456 size_t lineNumber() const nothrow @safe @nogc { 457 return _lineNumber; 458 } 459 460 private: 461 size_t _lineNumber; 462 } 463 464 auto iniLikeFileReader(string fileName) 465 { 466 return iniLikeRangeReader(File(fileName, "r").byLine().map!(s => s.idup)); 467 } 468 469 auto iniLikeStringReader(string contents) 470 { 471 return iniLikeRangeReader(contents.splitLines()); 472 } 473 474 auto iniLikeRangeReader(Range)(Range byLine) 475 { 476 return byLine.map!(function(string line) { 477 line = strip(line); 478 if (line.empty || line.startsWith("#")) { 479 return IniLikeLine.fromComment(line); 480 } else if (line.startsWith("[") && line.endsWith("]")) { 481 return IniLikeLine.fromGroupName(line[1..$-1]); 482 } else { 483 auto t = line.findSplit("="); 484 auto key = t[0].stripRight(); 485 auto value = t[2].stripLeft(); 486 487 if (t[1].length) { 488 return IniLikeLine.fromKeyValue(key, value); 489 } else { 490 return IniLikeLine(); 491 } 492 } 493 }); 494 } 495 496 class IniLikeFile 497 { 498 public: 499 ///Flags to manage .ini like file reading 500 enum ReadOptions 501 { 502 noOptions = 0, /// Read all groups and skip comments and empty lines. 503 firstGroupOnly = 1, /// Ignore other groups than the first one. 504 preserveComments = 2 /// Preserve comments and empty lines. Use this when you want to preserve them across writing. 505 } 506 507 /** 508 * Reads desktop file from file. 509 * Throws: 510 * $(B ErrnoException) if file could not be opened. 511 * $(B IniLikeException) if error occured while reading the file. 512 */ 513 static IniLikeFile loadFromFile(string fileName, ReadOptions options = ReadOptions.noOptions) @trusted { 514 return new IniLikeFile(iniLikeFileReader(fileName), options, fileName); 515 } 516 517 /** 518 * Reads desktop file from string. 519 * Throws: 520 * $(B IniLikeException) if error occured while parsing the contents. 521 */ 522 static IniLikeFile loadFromString(string contents, ReadOptions options = ReadOptions.noOptions, string fileName = null) @trusted { 523 return new IniLikeFile(iniLikeStringReader(contents), options, fileName); 524 } 525 526 this() { 527 528 } 529 530 this(Range)(Range byLine, ReadOptions options = ReadOptions.noOptions, string fileName = null) @trusted 531 { 532 size_t lineNumber = 0; 533 IniLikeGroup currentGroup; 534 535 try { 536 foreach(line; byLine) 537 { 538 lineNumber++; 539 final switch(line.type) 540 { 541 case IniLikeLine.Type.Comment: 542 { 543 if (options & ReadOptions.preserveComments) { 544 if (currentGroup is null) { 545 addFirstComment(line.comment); 546 } else { 547 currentGroup.addComment(line.comment); 548 } 549 } 550 } 551 break; 552 case IniLikeLine.Type.GroupStart: 553 { 554 enforce(line.groupName.length, "empty group name"); 555 enforce(group(line.groupName) is null, "group is defined more than once"); 556 557 currentGroup = addGroup(line.groupName); 558 559 if (options & ReadOptions.firstGroupOnly) { 560 break; 561 } 562 } 563 break; 564 case IniLikeLine.Type.KeyValue: 565 { 566 enforce(currentGroup, "met key-value pair before any group"); 567 currentGroup[line.key] = line.value; 568 } 569 break; 570 case IniLikeLine.Type.None: 571 { 572 throw new Exception("not key-value pair, nor group start nor comment"); 573 } 574 } 575 } 576 577 _fileName = fileName; 578 } 579 catch (Exception e) { 580 throw new IniLikeException(e.msg, lineNumber, e.file, e.line, e.next); 581 } 582 } 583 584 /** 585 * Returns: IniLikeGroup instance associated with groupName or $(B null) if not found. 586 */ 587 inout(IniLikeGroup) group(string groupName) @safe @nogc nothrow inout { 588 auto pick = groupName in _groupIndices; 589 if (pick) { 590 return _groups[*pick]; 591 } 592 return null; 593 } 594 595 /** 596 * Creates new group usin groupName. 597 * Returns: newly created instance of IniLikeGroup. 598 * Throws: Exception if group with such name already exists or groupName is empty. 599 */ 600 IniLikeGroup addGroup(string groupName) @safe { 601 enforce(groupName.length, "group name is empty"); 602 603 auto iniLikeGroup = new IniLikeGroup(groupName); 604 enforce(group(groupName) is null, "group already exists"); 605 _groupIndices[groupName] = _groups.length; 606 _groups ~= iniLikeGroup; 607 608 return iniLikeGroup; 609 } 610 611 /** 612 * Removes group by name. 613 */ 614 void removeGroup(string groupName) @safe nothrow { 615 auto pick = groupName in _groupIndices; 616 if (pick) { 617 _groups[*pick] = null; 618 } 619 } 620 621 /** 622 * Range of groups in order how they were defined in file. 623 */ 624 auto byGroup() inout { 625 return _groups[]; 626 } 627 628 /** 629 * Saves object to file using .ini like format. 630 * Throws: ErrnoException if the file could not be opened or an error writing to the file occured. 631 */ 632 void saveToFile(string fileName) const { 633 auto f = File(fileName, "w"); 634 void dg(string line) { 635 f.writeln(line); 636 } 637 save(&dg); 638 } 639 640 /** 641 * Saves object to string using .ini like format. 642 */ 643 string saveToString() const { 644 auto a = appender!(string[])(); 645 void dg(string line) { 646 a.put(line); 647 } 648 save(&dg); 649 return a.data.join("\n"); 650 } 651 652 private alias SaveDelegate = void delegate(string); 653 654 private void save(SaveDelegate sink) const { 655 foreach(line; firstComments()) { 656 sink(line); 657 } 658 659 foreach(group; byGroup()) { 660 sink("[" ~ group.name ~ "]"); 661 foreach(line; group._values) { 662 if (line.type == IniLikeLine.Type.Comment) { 663 sink(line.comment); 664 } else if (line.type == IniLikeLine.Type.KeyValue) { 665 sink(line.key ~ "=" ~ line.value); 666 } 667 } 668 } 669 } 670 671 /** 672 * Returns: file name as was specified on the object creation. 673 */ 674 string fileName() @safe @nogc nothrow const { 675 return _fileName; 676 } 677 678 protected: 679 auto firstComments() const nothrow @safe @nogc { 680 return _firstComments; 681 } 682 683 void addFirstComment(string line) nothrow @safe { 684 _firstComments ~= line; 685 } 686 687 private: 688 string _fileName; 689 size_t[string] _groupIndices; 690 IniLikeGroup[] _groups; 691 string[] _firstComments; 692 } 693 694 unittest 695 { 696 //Test locale-related functions 697 assert(makeLocaleName("ru", "RU") == "ru_RU"); 698 assert(makeLocaleName("ru", "RU", "UTF-8") == "ru_RU.UTF-8"); 699 assert(makeLocaleName("ru", "RU", "UTF-8", "mod") == "ru_RU.UTF-8@mod"); 700 assert(makeLocaleName("ru", null, null, "mod") == "ru@mod"); 701 702 assert(parseLocaleName("ru_RU.UTF-8@mod") == tuple("ru", "RU", "UTF-8", "mod")); 703 assert(parseLocaleName("ru@mod") == tuple("ru", string.init, string.init, "mod")); 704 assert(parseLocaleName("ru_RU") == tuple("ru", "RU", string.init, string.init)); 705 706 assert(localizedKey("Name", "ru_RU") == "Name[ru_RU]"); 707 assert(localizedKey("Name", "ru_RU.UTF-8") == "Name[ru_RU]"); 708 assert(localizedKey("Name", "ru", "RU") == "Name[ru_RU]"); 709 710 assert(separateFromLocale("Name[ru_RU]") == tuple("Name", "ru_RU")); 711 assert(separateFromLocale("Name") == tuple("Name", string.init)); 712 713 //Test locale matching lookup 714 auto group = new IniLikeGroup("Entry"); 715 assert(group.name == "Entry"); 716 group["Name"] = "Programmer"; 717 group["Name[ru_RU]"] = "Разработчик"; 718 group["Name[ru@jargon]"] = "Кодер"; 719 group["Name[ru]"] = "Программист"; 720 group["GenericName"] = "Program"; 721 group["GenericName[ru]"] = "Программа"; 722 assert(group["Name"] == "Programmer"); 723 assert(group.localizedValue("Name", "ru@jargon") == "Кодер"); 724 assert(group.localizedValue("Name", "ru_RU@jargon") == "Разработчик"); 725 assert(group.localizedValue("Name", "ru") == "Программист"); 726 assert(group.localizedValue("Name", "nonexistent locale") == "Programmer"); 727 assert(group.localizedValue("GenericName", "ru_RU") == "Программа"); 728 729 //Test escaping and unescaping 730 assert("\\next\nline".escapeValue() == `\\next\nline`); 731 assert(`\\next\nline`.unescapeValue() == "\\next\nline"); 732 }