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 }