1 /** 2 * Common functions for dealing with entries in ini-like file. 3 * Authors: 4 * $(LINK2 https://github.com/MyLittleRobo, Roman Chistokhodov) 5 * Copyright: 6 * Roman Chistokhodov, 2015-2016 7 * License: 8 * $(LINK2 http://www.boost.org/LICENSE_1_0.txt, Boost License 1.0). 9 * See_Also: 10 * $(LINK2 http://standards.freedesktop.org/desktop-entry-spec/latest/index.html, Desktop Entry Specification) 11 */ 12 13 module inilike.common; 14 15 package { 16 import std.algorithm; 17 import std.range; 18 import std.string; 19 import std.traits; 20 import std.typecons; 21 import std.conv : to; 22 23 static if( __VERSION__ < 2066 ) enum nogc = 1; 24 25 auto keyValueTuple(String)(String key, String value) 26 { 27 alias KeyValueTuple = Tuple!(String, "key", String, "value"); 28 return KeyValueTuple(key, value); 29 } 30 } 31 32 private @nogc @safe auto simpleStripLeft(inout(char)[] s) pure nothrow 33 { 34 size_t spaceNum = 0; 35 while(spaceNum < s.length) { 36 const char c = s[spaceNum]; 37 if (c == ' ' || c == '\t') { 38 spaceNum++; 39 } else { 40 break; 41 } 42 } 43 return s[spaceNum..$]; 44 } 45 46 private @nogc @safe auto simpleStripRight(inout(char)[] s) pure nothrow 47 { 48 size_t spaceNum = 0; 49 while(spaceNum < s.length) { 50 const char c = s[$-1-spaceNum]; 51 if (c == ' ' || c == '\t') { 52 spaceNum++; 53 } else { 54 break; 55 } 56 } 57 58 return s[0..$-spaceNum]; 59 } 60 61 62 /** 63 * Test whether the string s represents a comment. 64 */ 65 @nogc @safe bool isComment(const(char)[] s) pure nothrow 66 { 67 s = s.simpleStripLeft; 68 return !s.empty && s[0] == '#'; 69 } 70 71 /// 72 unittest 73 { 74 assert( isComment("# Comment")); 75 assert( isComment(" # Comment")); 76 assert(!isComment("Not comment")); 77 assert(!isComment("")); 78 } 79 80 /** 81 * Test whether the string s represents a group header. 82 * Note: "[]" is not considered as valid group header. 83 */ 84 @nogc @safe bool isGroupHeader(const(char)[] s) pure nothrow 85 { 86 s = s.simpleStripRight; 87 return s.length > 2 && s[0] == '[' && s[$-1] == ']'; 88 } 89 90 /// 91 unittest 92 { 93 assert( isGroupHeader("[Group]")); 94 assert( isGroupHeader("[Group] ")); 95 assert(!isGroupHeader("[]")); 96 assert(!isGroupHeader("[Group")); 97 assert(!isGroupHeader("Group]")); 98 } 99 100 /** 101 * Retrieve group name from header entry. 102 * Returns: group name or empty string if the entry is not group header. 103 */ 104 105 @nogc @safe auto parseGroupHeader(inout(char)[] s) pure nothrow 106 { 107 s = s.simpleStripRight; 108 if (isGroupHeader(s)) { 109 return s[1..$-1]; 110 } else { 111 return null; 112 } 113 } 114 115 /// 116 unittest 117 { 118 assert(parseGroupHeader("[Group name]") == "Group name"); 119 assert(parseGroupHeader("NotGroupName") == string.init); 120 121 assert(parseGroupHeader("[Group name]".dup) == "Group name".dup); 122 } 123 124 /** 125 * Parse entry of kind Key=Value into pair of Key and Value. 126 * Returns: tuple of key and value strings or tuple of empty strings if it's is not a key-value entry. 127 * Note: this function does not check whether parsed key is valid key. 128 */ 129 @nogc @trusted auto parseKeyValue(String)(String s) pure nothrow if (isSomeString!String && is(ElementEncodingType!String : char)) 130 { 131 auto t = s.findSplit("="); 132 auto key = t[0]; 133 auto value = t[2]; 134 135 if (key.length && t[1].length) { 136 return keyValueTuple(key, value); 137 } 138 return keyValueTuple(String.init, String.init); 139 } 140 141 /// 142 unittest 143 { 144 assert(parseKeyValue("Key=Value") == tuple("Key", "Value")); 145 assert(parseKeyValue("Key=") == tuple("Key", string.init)); 146 assert(parseKeyValue("=Value") == tuple(string.init, string.init)); 147 assert(parseKeyValue("NotKeyValue") == tuple(string.init, string.init)); 148 149 assert(parseKeyValue("Key=Value".dup) == tuple("Key".dup, "Value".dup)); 150 } 151 152 /** 153 * Test whether the string is valid key in terms of Desktop File Specification. Not actually used in inilike.file.IniLikeFile, but can be used in derivatives. 154 * 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) 155 * Note: this function automatically separate key from locale. It does not check validity of the locale itself. 156 */ 157 @nogc @safe bool isValidKey(String)(String key) pure nothrow if (isSomeString!String && is(ElementEncodingType!String : char)) { 158 key = separateFromLocale(key)[0]; 159 160 @nogc @safe static bool isValidKeyChar(ElementType!String c) pure nothrow { 161 return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-'; 162 } 163 164 if (key.empty) { 165 return false; 166 } 167 for (size_t i = 0; i<key.length; ++i) { 168 if (!isValidKeyChar(key[i])) { 169 return false; 170 } 171 } 172 return true; 173 } 174 175 /// 176 unittest 177 { 178 assert(isValidKey("Generic-Name")); 179 assert(isValidKey("Generic-Name[ru_RU]")); 180 assert(!isValidKey("Name$")); 181 assert(!isValidKey("")); 182 assert(!isValidKey("[ru_RU]")); 183 } 184 185 /** 186 * Test whether the entry value represents true 187 */ 188 @nogc @safe bool isTrue(const(char)[] value) pure nothrow { 189 return (value == "true" || value == "1"); 190 } 191 192 /// 193 unittest 194 { 195 assert(isTrue("true")); 196 assert(isTrue("1")); 197 assert(!isTrue("not boolean")); 198 } 199 200 /** 201 * Test whether the entry value represents false 202 */ 203 @nogc @safe bool isFalse(const(char)[] value) pure nothrow { 204 return (value == "false" || value == "0"); 205 } 206 207 /// 208 unittest 209 { 210 assert(isFalse("false")); 211 assert(isFalse("0")); 212 assert(!isFalse("not boolean")); 213 } 214 215 /** 216 * Check if the entry value can be interpreted as boolean value. 217 * See_Also: isTrue, isFalse 218 */ 219 @nogc @safe bool isBoolean(const(char)[] value) pure nothrow { 220 return isTrue(value) || isFalse(value); 221 } 222 223 /// 224 unittest 225 { 226 assert(isBoolean("true")); 227 assert(isBoolean("1")); 228 assert(isBoolean("false")); 229 assert(isBoolean("0")); 230 assert(!isBoolean("not boolean")); 231 } 232 233 /** 234 * Convert bool to string. Can be used to set boolean values. 235 */ 236 @nogc @safe string boolToString(bool b) nothrow pure { 237 return b ? "true" : "false"; 238 } 239 240 /// 241 unittest 242 { 243 assert(boolToString(false) == "false"); 244 assert(boolToString(true) == "true"); 245 } 246 247 /** 248 * Make locale name based on language, country, encoding and modifier. 249 * Returns: locale name in form lang_COUNTRY.ENCODING@MODIFIER 250 * See_Also: parseLocaleName 251 */ 252 @safe String makeLocaleName(String)( 253 String lang, String country = null, 254 String encoding = null, 255 String modifier = null) pure 256 if (isSomeString!String && is(ElementEncodingType!String : char)) 257 { 258 return lang ~ (country.length ? "_".to!String~country : String.init) 259 ~ (encoding.length ? ".".to!String~encoding : String.init) 260 ~ (modifier.length ? "@".to!String~modifier : String.init); 261 } 262 263 /// 264 unittest 265 { 266 assert(makeLocaleName("ru", "RU") == "ru_RU"); 267 assert(makeLocaleName("ru", "RU", "UTF-8") == "ru_RU.UTF-8"); 268 assert(makeLocaleName("ru", "RU", "UTF-8", "mod") == "ru_RU.UTF-8@mod"); 269 assert(makeLocaleName("ru", string.init, string.init, "mod") == "ru@mod"); 270 271 assert(makeLocaleName("ru".dup, (char[]).init, (char[]).init, "mod".dup) == "ru@mod".dup); 272 } 273 274 /** 275 * Parse locale name into the tuple of 4 values corresponding to language, country, encoding and modifier 276 * Returns: Tuple!(string, "lang", string, "country", string, "encoding", string, "modifier") 277 * See_Also: makeLocaleName 278 */ 279 @nogc @trusted auto parseLocaleName(String)(String locale) pure nothrow if (isSomeString!String && is(ElementEncodingType!String : char)) 280 { 281 auto modifiderSplit = findSplit(locale, "@"); 282 auto modifier = modifiderSplit[2]; 283 284 auto encodongSplit = findSplit(modifiderSplit[0], "."); 285 auto encoding = encodongSplit[2]; 286 287 auto countrySplit = findSplit(encodongSplit[0], "_"); 288 auto country = countrySplit[2]; 289 290 auto lang = countrySplit[0]; 291 292 alias LocaleTuple = Tuple!(String, "lang", String, "country", String, "encoding", String, "modifier"); 293 294 return LocaleTuple(lang, country, encoding, modifier); 295 } 296 297 /// 298 unittest 299 { 300 assert(parseLocaleName("ru_RU.UTF-8@mod") == tuple("ru", "RU", "UTF-8", "mod")); 301 assert(parseLocaleName("ru@mod") == tuple("ru", string.init, string.init, "mod")); 302 assert(parseLocaleName("ru_RU") == tuple("ru", "RU", string.init, string.init)); 303 304 assert(parseLocaleName("ru_RU.UTF-8@mod".dup) == tuple("ru".dup, "RU".dup, "UTF-8".dup, "mod".dup)); 305 } 306 307 /** 308 * Drop encoding part from locale (it's not used in constructing localized keys). 309 * Returns: Locale string with encoding part dropped out or original string if encoding was not present. 310 */ 311 @safe String dropEncodingPart(String)(String locale) pure nothrow if (isSomeString!String && is(ElementEncodingType!String : char)) 312 { 313 auto t = parseLocaleName(locale); 314 if (!t.encoding.empty) { 315 return makeLocaleName(t.lang, t.country, String.init, t.modifier); 316 } 317 return locale; 318 } 319 320 /// 321 unittest 322 { 323 assert("ru_RU.UTF-8".dropEncodingPart() == "ru_RU"); 324 string locale = "ru_RU"; 325 assert(locale.dropEncodingPart() is locale); 326 } 327 328 /** 329 * Construct localized key name from key and locale. 330 * Returns: localized key in form key[locale] dropping encoding out if present. 331 * See_Also: separateFromLocale 332 */ 333 @safe String localizedKey(String)(String key, String locale) pure nothrow if (isSomeString!String && is(ElementEncodingType!String : char)) 334 { 335 if (locale.empty) { 336 return key; 337 } 338 return key ~ "[".to!String ~ locale.dropEncodingPart() ~ "]".to!String; 339 } 340 341 /// 342 unittest 343 { 344 string key = "Name"; 345 assert(localizedKey(key, "") == key); 346 assert(localizedKey("Name", "ru_RU") == "Name[ru_RU]"); 347 assert(localizedKey("Name", "ru_RU.UTF-8") == "Name[ru_RU]"); 348 } 349 350 /** 351 * ditto, but constructs locale name from arguments. 352 */ 353 @safe String localizedKey(String)(String key, String lang, String country, String modifier = null) pure if (isSomeString!String && is(ElementEncodingType!String : char)) 354 { 355 return key ~ "[".to!String ~ makeLocaleName(lang, country, String.init, modifier) ~ "]".to!String; 356 } 357 358 /// 359 unittest 360 { 361 assert(localizedKey("Name", "ru", "RU") == "Name[ru_RU]"); 362 assert(localizedKey("Name".dup, "ru".dup, "RU".dup) == "Name[ru_RU]".dup); 363 } 364 365 /** 366 * Separate key name into non-localized key and locale name. 367 * If key is not localized returns original key and empty string. 368 * Returns: tuple of key and locale name. 369 * See_Also: localizedKey 370 */ 371 @nogc @trusted auto separateFromLocale(String)(String key) pure nothrow if (isSomeString!String && is(ElementEncodingType!String : char)) { 372 if (key.endsWith("]")) { 373 auto t = key.findSplit("["); 374 if (t[1].length) { 375 return tuple(t[0], t[2][0..$-1]); 376 } 377 } 378 return tuple(key, typeof(key).init); 379 } 380 381 /// 382 unittest 383 { 384 assert(separateFromLocale("Name[ru_RU]") == tuple("Name", "ru_RU")); 385 assert(separateFromLocale("Name") == tuple("Name", string.init)); 386 387 char[] mutableString = "Hello".dup; 388 assert(separateFromLocale(mutableString) == tuple(mutableString, typeof(mutableString).init)); 389 } 390 391 /** 392 * Choose the better localized value matching to locale between two localized values. The "goodness" is determined using algorithm described in $(LINK2 http://standards.freedesktop.org/desktop-entry-spec/latest/ar01s04.html, Localized values for keys). 393 * Params: 394 * locale = original locale to match to 395 * firstLocale = first locale 396 * firstValue = first value 397 * secondLocale = second locale 398 * secondValue = second value 399 * Returns: The best alternative among two or empty string if none of alternatives match original locale. 400 * Note: value with empty locale is considered better choice than value with locale that does not match the original one. 401 */ 402 @nogc @trusted auto chooseLocalizedValue(String)( 403 String locale, 404 String firstLocale, String firstValue, 405 String secondLocale, String secondValue) pure nothrow 406 if (isSomeString!String && is(ElementEncodingType!String : char)) 407 { 408 const lt = parseLocaleName(locale); 409 const lt1 = parseLocaleName(firstLocale); 410 const lt2 = parseLocaleName(secondLocale); 411 412 int score1, score2; 413 414 if (lt.lang == lt1.lang) { 415 score1 = 1 + ((lt.country == lt1.country) ? 2 : 0 ) + ((lt.modifier == lt1.modifier) ? 1 : 0); 416 } 417 if (lt.lang == lt2.lang) { 418 score2 = 1 + ((lt.country == lt2.country) ? 2 : 0 ) + ((lt.modifier == lt2.modifier) ? 1 : 0); 419 } 420 421 if (score1 == 0 && score2 == 0) { 422 if (firstLocale.empty && !firstValue.empty) { 423 return tuple(firstLocale, firstValue); 424 } else if (secondLocale.empty && !secondValue.empty) { 425 return tuple(secondLocale, secondValue); 426 } else { 427 return tuple(String.init, String.init); 428 } 429 } 430 431 if (score1 >= score2) { 432 return tuple(firstLocale, firstValue); 433 } else { 434 return tuple(secondLocale, secondValue); 435 } 436 } 437 438 /// 439 unittest 440 { 441 string locale = "ru_RU.UTF-8@jargon"; 442 assert(chooseLocalizedValue(string.init, "ru_RU", "Программист", "ru@jargon", "Кодер") == tuple(string.init, string.init)); 443 assert(chooseLocalizedValue(locale, "fr_FR", "Programmeur", string.init, "Programmer") == tuple(string.init, "Programmer")); 444 assert(chooseLocalizedValue(locale, string.init, "Programmer", "de_DE", "Programmierer") == tuple(string.init, "Programmer")); 445 assert(chooseLocalizedValue(locale, "fr_FR", "Programmeur", "de_DE", "Programmierer") == tuple(string.init, string.init)); 446 447 assert(chooseLocalizedValue(string.init, string.init, "Value", string.init, string.init) == tuple(string.init, "Value")); 448 assert(chooseLocalizedValue(locale, string.init, "Value", string.init, string.init) == tuple(string.init, "Value")); 449 assert(chooseLocalizedValue(locale, string.init, string.init, string.init, "Value") == tuple(string.init, "Value")); 450 451 assert(chooseLocalizedValue(locale, "ru_RU", "Программист", "ru@jargon", "Кодер") == tuple("ru_RU", "Программист")); 452 assert(chooseLocalizedValue(locale, "ru_RU", "Программист", "ru_RU@jargon", "Кодер") == tuple("ru_RU@jargon", "Кодер")); 453 454 assert(chooseLocalizedValue(locale, "ru", "Разработчик", "ru_RU", "Программист") == tuple("ru_RU", "Программист")); 455 } 456 457 /** 458 * Check if value needs to be escaped. This function is currently tolerant to single slashes and tabs. 459 * Returns: true if value needs to escaped, false otherwise. 460 */ 461 @nogc @safe bool needEscaping(String)(String value) nothrow pure if (isSomeString!String && is(ElementEncodingType!String : char)) 462 { 463 for (size_t i=0; i<value.length; ++i) { 464 const c = value[i]; 465 if (c == '\n' || c == '\r') { 466 return true; 467 } 468 } 469 return false; 470 } 471 472 /// 473 unittest 474 { 475 assert("new\nline".needEscaping); 476 assert(!`i have \ slash`.needEscaping); 477 assert("i like\rcarriage\rreturns".needEscaping); 478 assert(!"just a text".needEscaping); 479 } 480 481 /** 482 * Escapes string by replacing special symbols with escaped sequences. 483 * These symbols are: '\\' (backslash), '\n' (newline), '\r' (carriage return) and '\t' (tab). 484 * Returns: Escaped string. 485 * See_Also: unescapeValue 486 */ 487 @trusted String escapeValue(String)(String value) pure if (isSomeString!String && is(ElementEncodingType!String : char)) { 488 return value.replace("\\", `\\`.to!String).replace("\n", `\n`.to!String).replace("\r", `\r`.to!String).replace("\t", `\t`.to!String); 489 } 490 491 /// 492 unittest 493 { 494 assert("a\\next\nline\top".escapeValue() == `a\\next\nline\top`); // notice how the string on the right is raw. 495 assert("a\\next\nline\top".dup.escapeValue() == `a\\next\nline\top`.dup); 496 } 497 498 499 /** 500 * Unescape value. If value does not need unescaping this function returns original value. 501 * Params: 502 * value = string to unescape 503 * pairs = pairs of escaped characters and their unescaped forms. 504 */ 505 @trusted inout(char)[] doUnescape(inout(char)[] value, in Tuple!(char, char)[] pairs) nothrow pure { 506 //little optimization to avoid unneeded allocations. 507 size_t i = 0; 508 for (; i < value.length; i++) { 509 if (value[i] == '\\') { 510 break; 511 } 512 } 513 if (i == value.length) { 514 return value; 515 } 516 517 auto toReturn = appender!(typeof(value))(); 518 toReturn.put(value[0..i]); 519 520 for (; i < value.length; i++) { 521 if (value[i] == '\\') { 522 if (i+1 < value.length) { 523 const char c = value[i+1]; 524 auto t = pairs.find!"a[0] == b[0]"(tuple(c,c)); 525 if (!t.empty) { 526 toReturn.put(t.front[1]); 527 i++; 528 continue; 529 } 530 } 531 } 532 toReturn.put(value[i]); 533 } 534 return toReturn.data; 535 } 536 537 unittest 538 { 539 enum Tuple!(char, char)[] pairs = [tuple('\\', '\\')]; 540 static assert(is(typeof(doUnescape("", pairs)) == string)); 541 static assert(is(typeof(doUnescape("".dup, pairs)) == char[])); 542 } 543 544 545 /** 546 * 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). 547 * Returns: Unescaped string. 548 * See_Also: escapeValue 549 */ 550 @safe inout(char)[] unescapeValue(inout(char)[] value) nothrow pure 551 { 552 static immutable Tuple!(char, char)[] pairs = [ 553 tuple('s', ' '), 554 tuple('n', '\n'), 555 tuple('r', '\r'), 556 tuple('t', '\t'), 557 tuple('\\', '\\') 558 ]; 559 return doUnescape(value, pairs); 560 } 561 562 /// 563 unittest 564 { 565 assert(`a\\next\nline\top`.unescapeValue() == "a\\next\nline\top"); // notice how the string on the left is raw. 566 assert(`\\next\nline\top`.unescapeValue() == "\\next\nline\top"); 567 string value = `nounescape`; 568 assert(value.unescapeValue() is value); //original is returned. 569 assert(`a\\next\nline\top`.dup.unescapeValue() == "a\\next\nline\top".dup); 570 }