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;
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 private @nogc @safe bool simpleCanFind(in char[] str, char c) pure nothrow
153 {
154     for (size_t i=0; i<str.length; ++i) {
155         if (str[i] == c) {
156             return true;
157         }
158     }
159     return false;
160 }
161 
162 /**
163  * Test whether the string is valid key, i.e. does not need escaping, is not a comment and not empty string.
164  */
165 @nogc @safe bool isValidKey(in char[] key) pure nothrow
166 {
167     if (key.empty || key.simpleStripLeft.simpleStripRight.empty) {
168         return false;
169     }
170     if (key.isComment || key.simpleCanFind('=') || key.needEscaping()) {
171         return false;
172     }
173     return true;
174 }
175 
176 ///
177 unittest
178 {
179     assert(isValidKey("Valid key"));
180     assert(!isValidKey(""));
181     assert(!isValidKey("    "));
182     assert(!isValidKey("Sneaky\nKey"));
183     assert(!isValidKey("# Sneaky key"));
184     assert(!isValidKey("Sneaky=key"));
185 }
186 
187 /**
188 * Test whether the string is valid key in terms of Desktop File Specification.
189 *
190 * Not actually used in $(D inilike.file.IniLikeFile), but can be used in derivatives.
191 * Only the characters A-Za-z0-9- may be used in key names.
192 * Note: this function automatically separate key from locale. Locale is validated against isValidKey.
193 * See_Also: $(LINK2 http://standards.freedesktop.org/desktop-entry-spec/latest/ar01s02.html, Basic format of the file), $(D isValidKey)
194 */
195 @nogc @safe bool isValidDesktopFileKey(in char[] desktopKey) pure nothrow {
196     auto t = separateFromLocale(desktopKey);
197     auto key = t[0];
198     auto locale = t[1];
199 
200     if (locale.length && !isValidKey(locale)) {
201         return false;
202     }
203 
204     @nogc @safe static bool isValidDesktopFileKeyChar(char c) pure nothrow {
205         return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-';
206     }
207 
208     if (key.empty) {
209         return false;
210     }
211     for (size_t i = 0; i<key.length; ++i) {
212         if (!isValidDesktopFileKeyChar(key[i])) {
213             return false;
214         }
215     }
216     return true;
217 }
218 
219 ///
220 unittest
221 {
222     assert(isValidDesktopFileKey("Generic-Name"));
223     assert(isValidDesktopFileKey("Generic-Name[ru_RU]"));
224     assert(!isValidDesktopFileKey("Name$"));
225     assert(!isValidDesktopFileKey(""));
226     assert(!isValidDesktopFileKey("[ru_RU]"));
227     assert(!isValidDesktopFileKey("Name[ru\nRU]"));
228 }
229 
230 /**
231  * Test whether the entry value represents true.
232  * See_Also: $(D isFalse), $(D isBoolean)
233  */
234 @nogc @safe bool isTrue(const(char)[] value) pure nothrow {
235     return (value == "true" || value == "1");
236 }
237 
238 ///
239 unittest
240 {
241     assert(isTrue("true"));
242     assert(isTrue("1"));
243     assert(!isTrue("not boolean"));
244 }
245 
246 /**
247  * Test whether the entry value represents false.
248  * See_Also: $(D isTrue), $(D isBoolean)
249  */
250 @nogc @safe bool isFalse(const(char)[] value) pure nothrow {
251     return (value == "false" || value == "0");
252 }
253 
254 ///
255 unittest
256 {
257     assert(isFalse("false"));
258     assert(isFalse("0"));
259     assert(!isFalse("not boolean"));
260 }
261 
262 /**
263  * Check if the entry value can be interpreted as boolean value.
264  * See_Also: $(D isTrue), $(D isFalse)
265  */
266 @nogc @safe bool isBoolean(const(char)[] value) pure nothrow {
267     return isTrue(value) || isFalse(value);
268 }
269 
270 ///
271 unittest
272 {
273     assert(isBoolean("true"));
274     assert(isBoolean("1"));
275     assert(isBoolean("false"));
276     assert(isBoolean("0"));
277     assert(!isBoolean("not boolean"));
278 }
279 
280 /**
281  * Convert bool to string. Can be used to set boolean values.
282  * See_Also: $(D isBoolean)
283  */
284 @nogc @safe string boolToString(bool b) nothrow pure {
285     return b ? "true" : "false";
286 }
287 
288 ///
289 unittest
290 {
291     assert(boolToString(false) == "false");
292     assert(boolToString(true) == "true");
293 }
294 
295 /**
296  * Make locale name based on language, country, encoding and modifier.
297  * Returns: locale name in form lang_COUNTRY.ENCODING@MODIFIER
298  * See_Also: $(D parseLocaleName)
299  */
300 @safe String makeLocaleName(String)(
301     String lang, String country = null,
302     String encoding = null,
303     String modifier = null) pure
304 if (isSomeString!String && is(ElementEncodingType!String : char))
305 {
306     return lang ~ (country.length ? "_".to!String~country : String.init)
307                 ~ (encoding.length ? ".".to!String~encoding : String.init)
308                 ~ (modifier.length ? "@".to!String~modifier : String.init);
309 }
310 
311 ///
312 unittest
313 {
314     assert(makeLocaleName("ru", "RU") == "ru_RU");
315     assert(makeLocaleName("ru", "RU", "UTF-8") == "ru_RU.UTF-8");
316     assert(makeLocaleName("ru", "RU", "UTF-8", "mod") == "ru_RU.UTF-8@mod");
317     assert(makeLocaleName("ru", string.init, string.init, "mod") == "ru@mod");
318 
319     assert(makeLocaleName("ru".dup, (char[]).init, (char[]).init, "mod".dup) == "ru@mod".dup);
320 }
321 
322 /**
323  * Parse locale name into the tuple of 4 values corresponding to language, country, encoding and modifier
324  * Returns: Tuple!(string, "lang", string, "country", string, "encoding", string, "modifier")
325  * See_Also: $(D makeLocaleName)
326  */
327 @nogc @trusted auto parseLocaleName(String)(String locale) pure nothrow if (isSomeString!String && is(ElementEncodingType!String : char))
328 {
329     auto modifiderSplit = findSplit(locale, "@");
330     auto modifier = modifiderSplit[2];
331 
332     auto encodongSplit = findSplit(modifiderSplit[0], ".");
333     auto encoding = encodongSplit[2];
334 
335     auto countrySplit = findSplit(encodongSplit[0], "_");
336     auto country = countrySplit[2];
337 
338     auto lang = countrySplit[0];
339 
340     alias LocaleTuple = Tuple!(String, "lang", String, "country", String, "encoding", String, "modifier");
341 
342     return LocaleTuple(lang, country, encoding, modifier);
343 }
344 
345 ///
346 unittest
347 {
348     assert(parseLocaleName("ru_RU.UTF-8@mod") == tuple("ru", "RU", "UTF-8", "mod"));
349     assert(parseLocaleName("ru@mod") == tuple("ru", string.init, string.init, "mod"));
350     assert(parseLocaleName("ru_RU") == tuple("ru", "RU", string.init, string.init));
351 
352     assert(parseLocaleName("ru_RU.UTF-8@mod".dup) == tuple("ru".dup, "RU".dup, "UTF-8".dup, "mod".dup));
353 }
354 
355 /**
356  * Drop encoding part from locale (it's not used in constructing localized keys).
357  * Returns: Locale string with encoding part dropped out or original string if encoding was not present.
358  */
359 @safe String dropEncodingPart(String)(String locale) pure nothrow if (isSomeString!String && is(ElementEncodingType!String : char))
360 {
361     auto t = parseLocaleName(locale);
362     if (!t.encoding.empty) {
363         return makeLocaleName(t.lang, t.country, String.init, t.modifier);
364     }
365     return locale;
366 }
367 
368 ///
369 unittest
370 {
371     assert("ru_RU.UTF-8".dropEncodingPart() == "ru_RU");
372     string locale = "ru_RU";
373     assert(locale.dropEncodingPart() is locale);
374 }
375 
376 /**
377  * Construct localized key name from key and locale.
378  * Returns: localized key in form key[locale] dropping encoding out if present.
379  * See_Also: $(D separateFromLocale)
380  */
381 @safe String localizedKey(String)(String key, String locale) pure nothrow if (isSomeString!String && is(ElementEncodingType!String : char))
382 {
383     if (locale.empty) {
384         return key;
385     }
386     return key ~ "[".to!String ~ locale.dropEncodingPart() ~ "]".to!String;
387 }
388 
389 ///
390 unittest
391 {
392     string key = "Name";
393     assert(localizedKey(key, "") == key);
394     assert(localizedKey("Name", "ru_RU") == "Name[ru_RU]");
395     assert(localizedKey("Name", "ru_RU.UTF-8") == "Name[ru_RU]");
396 }
397 
398 /**
399  * ditto, but constructs locale name from arguments.
400  */
401 @safe String localizedKey(String)(String key, String lang, String country, String modifier = null) pure if (isSomeString!String && is(ElementEncodingType!String : char))
402 {
403     return key ~ "[".to!String ~ makeLocaleName(lang, country, String.init, modifier) ~ "]".to!String;
404 }
405 
406 ///
407 unittest
408 {
409     assert(localizedKey("Name", "ru", "RU") == "Name[ru_RU]");
410     assert(localizedKey("Name".dup, "ru".dup, "RU".dup) == "Name[ru_RU]".dup);
411 }
412 
413 /**
414  * Separate key name into non-localized key and locale name.
415  * If key is not localized returns original key and empty string.
416  * Returns: tuple of key and locale name.
417  * See_Also: $(D localizedKey)
418  */
419 @nogc @trusted auto separateFromLocale(String)(String key) pure nothrow if (isSomeString!String && is(ElementEncodingType!String : char)) {
420     if (key.endsWith("]")) {
421         auto t = key.findSplit("[");
422         if (t[1].length) {
423             return tuple(t[0], t[2][0..$-1]);
424         }
425     }
426     return tuple(key, typeof(key).init);
427 }
428 
429 ///
430 unittest
431 {
432     assert(separateFromLocale("Name[ru_RU]") == tuple("Name", "ru_RU"));
433     assert(separateFromLocale("Name") == tuple("Name", string.init));
434 
435     char[] mutableString = "Hello".dup;
436     assert(separateFromLocale(mutableString) == tuple(mutableString, typeof(mutableString).init));
437 }
438 
439 /**
440  * 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).
441  * Params:
442  *  locale = original locale to match to
443  *  firstLocale = first locale
444  *  firstValue = first value
445  *  secondLocale = second locale
446  *  secondValue = second value
447  * Returns: The best alternative among two or empty string if none of alternatives match original locale.
448  * Note: value with empty locale is considered better choice than value with locale that does not match the original one.
449  */
450 @nogc @trusted auto chooseLocalizedValue(String)(
451     String locale,
452     String firstLocale,  String firstValue,
453     String secondLocale, String secondValue) pure nothrow
454     if (isSomeString!String && is(ElementEncodingType!String : char))
455 {
456     const lt = parseLocaleName(locale);
457     const lt1 = parseLocaleName(firstLocale);
458     const lt2 = parseLocaleName(secondLocale);
459 
460     int score1, score2;
461 
462     if (lt.lang == lt1.lang) {
463         score1 = 1 + ((lt.country == lt1.country) ? 2 : 0 ) + ((lt.modifier == lt1.modifier) ? 1 : 0);
464     }
465     if (lt.lang == lt2.lang) {
466         score2 = 1 + ((lt.country == lt2.country) ? 2 : 0 ) + ((lt.modifier == lt2.modifier) ? 1 : 0);
467     }
468 
469     if (score1 == 0 && score2 == 0) {
470         if (firstLocale.empty && !firstValue.empty) {
471             return tuple(firstLocale, firstValue);
472         } else if (secondLocale.empty && !secondValue.empty) {
473             return tuple(secondLocale, secondValue);
474         } else {
475             return tuple(String.init, String.init);
476         }
477     }
478 
479     if (score1 >= score2) {
480         return tuple(firstLocale, firstValue);
481     } else {
482         return tuple(secondLocale, secondValue);
483     }
484 }
485 
486 ///
487 unittest
488 {
489     string locale = "ru_RU.UTF-8@jargon";
490     assert(chooseLocalizedValue(string.init, "ru_RU", "Программист", "ru@jargon", "Кодер") == tuple(string.init, string.init));
491     assert(chooseLocalizedValue(locale, "fr_FR", "Programmeur", string.init, "Programmer") == tuple(string.init, "Programmer"));
492     assert(chooseLocalizedValue(locale, string.init, "Programmer", "de_DE", "Programmierer") == tuple(string.init, "Programmer"));
493     assert(chooseLocalizedValue(locale, "fr_FR", "Programmeur", "de_DE", "Programmierer") == tuple(string.init, string.init));
494 
495     assert(chooseLocalizedValue(string.init, string.init, "Value", string.init, string.init) == tuple(string.init, "Value"));
496     assert(chooseLocalizedValue(locale, string.init, "Value", string.init, string.init) == tuple(string.init, "Value"));
497     assert(chooseLocalizedValue(locale, string.init, string.init, string.init, "Value") == tuple(string.init, "Value"));
498 
499     assert(chooseLocalizedValue(locale, "ru_RU", "Программист", "ru@jargon", "Кодер") == tuple("ru_RU", "Программист"));
500     assert(chooseLocalizedValue(locale, "ru_RU", "Программист", "ru_RU@jargon", "Кодер") == tuple("ru_RU@jargon", "Кодер"));
501 
502     assert(chooseLocalizedValue(locale, "ru", "Разработчик", "ru_RU", "Программист") == tuple("ru_RU", "Программист"));
503 }
504 
505 /**
506  * Check if value needs to be escaped. This function is currently tolerant to single slashes and tabs.
507  * Returns: true if value needs to escaped, false otherwise.
508  * See_Also: $(D escapeValue)
509  */
510 @nogc @safe bool needEscaping(String)(String value) nothrow pure if (isSomeString!String && is(ElementEncodingType!String : char))
511 {
512     for (size_t i=0; i<value.length; ++i) {
513         const c = value[i];
514         if (c == '\n' || c == '\r') {
515             return true;
516         }
517     }
518     return false;
519 }
520 
521 ///
522 unittest
523 {
524     assert("new\nline".needEscaping);
525     assert(!`i have \ slash`.needEscaping);
526     assert("i like\rcarriage\rreturns".needEscaping);
527     assert(!"just a text".needEscaping);
528 }
529 
530 /**
531  * Escapes string by replacing special symbols with escaped sequences.
532  * These symbols are: '\\' (backslash), '\n' (newline), '\r' (carriage return) and '\t' (tab).
533  * Returns: Escaped string.
534  * See_Also: $(D unescapeValue)
535  */
536 @trusted String escapeValue(String)(String value) pure if (isSomeString!String && is(ElementEncodingType!String : char)) {
537     return value.replace("\\", `\\`.to!String).replace("\n", `\n`.to!String).replace("\r", `\r`.to!String).replace("\t", `\t`.to!String);
538 }
539 
540 ///
541 unittest
542 {
543     assert("a\\next\nline\top".escapeValue() == `a\\next\nline\top`); // notice how the string on the right is raw.
544     assert("a\\next\nline\top".dup.escapeValue() == `a\\next\nline\top`.dup);
545 }
546 
547 
548 /**
549  * Unescape value. If value does not need unescaping this function returns original value.
550  * Params:
551  *  value = string to unescape
552  *  pairs = pairs of escaped characters and their unescaped forms.
553  */
554 @trusted inout(char)[] doUnescape(inout(char)[] value, in Tuple!(char, char)[] pairs) nothrow pure {
555     //little optimization to avoid unneeded allocations.
556     size_t i = 0;
557     for (; i < value.length; i++) {
558         if (value[i] == '\\') {
559             break;
560         }
561     }
562     if (i == value.length) {
563         return value;
564     }
565 
566     auto toReturn = appender!(typeof(value))();
567     toReturn.put(value[0..i]);
568 
569     for (; i < value.length; i++) {
570         if (value[i] == '\\' && i+1 < value.length) {
571             const char c = value[i+1];
572             auto t = pairs.find!"a[0] == b[0]"(tuple(c,c));
573             if (!t.empty) {
574                 toReturn.put(t.front[1]);
575                 i++;
576                 continue;
577             }
578         }
579         toReturn.put(value[i]);
580     }
581     return toReturn.data;
582 }
583 
584 unittest
585 {
586     enum Tuple!(char, char)[] pairs = [tuple('\\', '\\')];
587     static assert(is(typeof(doUnescape("", pairs)) == string));
588     static assert(is(typeof(doUnescape("".dup, pairs)) == char[]));
589 }
590 
591 
592 /**
593  * 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).
594  * Returns: Unescaped string.
595  * See_Also: $(D escapeValue), $(D doUnescape)
596  */
597 @safe inout(char)[] unescapeValue(inout(char)[] value) nothrow pure
598 {
599     static immutable Tuple!(char, char)[] pairs = [
600        tuple('s', ' '),
601        tuple('n', '\n'),
602        tuple('r', '\r'),
603        tuple('t', '\t'),
604        tuple('\\', '\\')
605     ];
606     return doUnescape(value, pairs);
607 }
608 
609 ///
610 unittest
611 {
612     assert(`a\\next\nline\top`.unescapeValue() == "a\\next\nline\top"); // notice how the string on the left is raw.
613     assert(`\\next\nline\top`.unescapeValue() == "\\next\nline\top");
614     string value = `nounescape`;
615     assert(value.unescapeValue() is value); //original is returned.
616     assert(`a\\next\nline\top`.dup.unescapeValue() == "a\\next\nline\top".dup);
617 }