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