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 @trusted auto stripLeftChar(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 @trusted auto stripRightChar(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 @trusted bool isComment(const(char)[] s) pure nothrow
66 {
67     s = s.stripLeftChar;
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 @trusted bool isGroupHeader(const(char)[] s) pure nothrow
85 {
86     s = s.stripRightChar;
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 @trusted auto parseGroupHeader(inout(char)[] s) pure nothrow
106 {
107     s = s.stripRightChar;
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  * See_Also: isValidKey
129  */
130 @nogc @trusted auto parseKeyValue(String)(String s) pure nothrow if (is(String : const(char)[]))
131 {
132     auto t = s.findSplit("=");
133     auto key = t[0];
134     auto value = t[2];
135     
136     if (key.length && t[1].length) {
137         return keyValueTuple(key, value);
138     }
139     return keyValueTuple(String.init, String.init);
140 }
141 
142 ///
143 unittest
144 {
145     assert(parseKeyValue("Key=Value") == tuple("Key", "Value"));
146     assert(parseKeyValue("Key=") == tuple("Key", string.init));
147     assert(parseKeyValue("=Value") == tuple(string.init, string.init));
148     assert(parseKeyValue("NotKeyValue") == tuple(string.init, string.init));
149     
150     assert(parseKeyValue("Key=Value".dup) == tuple("Key".dup, "Value".dup));
151 }
152 
153 /**
154 * Test whether the string is valid key. 
155 * 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)
156 * Note: this function automatically separate key from locale. It does not check validity of the locale itself.
157 */
158 @nogc @safe bool isValidKey(String)(String key) pure nothrow if (is(String : const(char)[])) {
159     key = separateFromLocale(key)[0];
160     
161     @nogc @safe static bool isValidKeyChar(char c) pure nothrow {
162         return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-';
163     }
164     
165     if (key.empty) {
166         return false;
167     }
168     for (size_t i = 0; i<key.length; ++i) {
169         if (!isValidKeyChar(key[i])) {
170             return false;
171         }
172     }
173     return true;
174 }
175 
176 ///
177 unittest
178 {
179     assert(isValidKey("Generic-Name"));
180     assert(isValidKey("Generic-Name[ru_RU]"));
181     assert(!isValidKey("Name$"));
182     assert(!isValidKey(""));
183     assert(!isValidKey("[ru_RU]"));
184 }
185 
186 /**
187  * Test whether the entry value represents true
188  */
189 @nogc @safe bool isTrue(const(char)[] value) pure nothrow {
190     return (value == "true" || value == "1");
191 }
192 
193 ///
194 unittest 
195 {
196     assert(isTrue("true"));
197     assert(isTrue("1"));
198     assert(!isTrue("not boolean"));
199 }
200 
201 /**
202  * Test whether the entry value represents false
203  */
204 @nogc @safe bool isFalse(const(char)[] value) pure nothrow {
205     return (value == "false" || value == "0");
206 }
207 
208 ///
209 unittest 
210 {
211     assert(isFalse("false"));
212     assert(isFalse("0"));
213     assert(!isFalse("not boolean"));
214 }
215 
216 /**
217  * Check if the entry value can be interpreted as boolean value.
218  * See_Also: isTrue, isFalse
219  */
220 @nogc @safe bool isBoolean(const(char)[] value) pure nothrow {
221     return isTrue(value) || isFalse(value);
222 }
223 
224 ///
225 unittest 
226 {
227     assert(isBoolean("true"));
228     assert(isBoolean("1"));
229     assert(isBoolean("false"));
230     assert(isBoolean("0"));
231     assert(!isBoolean("not boolean"));
232 }
233 
234 /**
235  * Make locale name based on language, country, encoding and modifier.
236  * Returns: locale name in form lang_COUNTRY.ENCODING@MODIFIER
237  * See_Also: parseLocaleName
238  */
239 @safe String makeLocaleName(String)(
240     String lang, String country = null, 
241     String encoding = null, 
242     String modifier = null) pure
243 if (is(String : const(char)[]))
244 {
245     return lang ~ (country.length ? "_".to!String~country : String.init)
246                 ~ (encoding.length ? ".".to!String~encoding : String.init)
247                 ~ (modifier.length ? "@".to!String~modifier : String.init);
248 }
249 
250 ///
251 unittest
252 {
253     assert(makeLocaleName("ru", "RU") == "ru_RU");
254     assert(makeLocaleName("ru", "RU", "UTF-8") == "ru_RU.UTF-8");
255     assert(makeLocaleName("ru", "RU", "UTF-8", "mod") == "ru_RU.UTF-8@mod");
256     assert(makeLocaleName("ru", string.init, string.init, "mod") == "ru@mod");
257     
258     assert(makeLocaleName("ru".dup, (char[]).init, (char[]).init, "mod".dup) == "ru@mod".dup);
259 }
260 
261 /**
262  * Parse locale name into the tuple of 4 values corresponding to language, country, encoding and modifier
263  * Returns: Tuple!(string, "lang", string, "country", string, "encoding", string, "modifier")
264  * See_Also: makeLocaleName
265  */
266 @nogc @trusted auto parseLocaleName(String)(String locale) pure nothrow if (is(String : const(char)[]))
267 {
268     auto modifiderSplit = findSplit(locale, "@");
269     auto modifier = modifiderSplit[2];
270     
271     auto encodongSplit = findSplit(modifiderSplit[0], ".");
272     auto encoding = encodongSplit[2];
273     
274     auto countrySplit = findSplit(encodongSplit[0], "_");
275     auto country = countrySplit[2];
276     
277     auto lang = countrySplit[0];
278     
279     alias LocaleTuple = Tuple!(String, "lang", String, "country", String, "encoding", String, "modifier");
280     
281     return LocaleTuple(lang, country, encoding, modifier);
282 }
283 
284 ///
285 unittest 
286 {
287     assert(parseLocaleName("ru_RU.UTF-8@mod") == tuple("ru", "RU", "UTF-8", "mod"));
288     assert(parseLocaleName("ru@mod") == tuple("ru", string.init, string.init, "mod"));
289     assert(parseLocaleName("ru_RU") == tuple("ru", "RU", string.init, string.init));
290     
291     assert(parseLocaleName("ru_RU.UTF-8@mod".dup) == tuple("ru".dup, "RU".dup, "UTF-8".dup, "mod".dup));
292 }
293 
294 /**
295  * Construct localized key name from key and locale.
296  * Returns: localized key in form key[locale] dropping encoding out if present.
297  * See_Also: separateFromLocale
298  */
299 @safe String localizedKey(String)(String key, String locale) pure nothrow if (is(String : const(char)[]))
300 {
301     auto t = parseLocaleName(locale);
302     if (!t.encoding.empty) {
303         locale = makeLocaleName(t.lang, t.country, String.init, t.modifier);
304     }
305     return key ~ "[".to!String ~ locale ~ "]".to!String;
306 }
307 
308 ///
309 unittest 
310 {
311     assert(localizedKey("Name", "ru_RU") == "Name[ru_RU]");
312     assert(localizedKey("Name", "ru_RU.UTF-8") == "Name[ru_RU]");
313 }
314 
315 /**
316  * ditto, but constructs locale name from arguments.
317  */
318 @safe String localizedKey(String)(String key, String lang, String country, String modifier = null) pure
319 {
320     return key ~ "[".to!String ~ makeLocaleName(lang, country, String.init, modifier) ~ "]".to!String;
321 }
322 
323 ///
324 unittest 
325 {
326     assert(localizedKey("Name", "ru", "RU") == "Name[ru_RU]");
327     assert(localizedKey("Name".dup, "ru".dup, "RU".dup) == "Name[ru_RU]".dup);
328 }
329 
330 /** 
331  * Separate key name into non-localized key and locale name.
332  * If key is not localized returns original key and empty string.
333  * Returns: tuple of key and locale name.
334  * See_Also: localizedKey
335  */
336 @nogc @trusted auto separateFromLocale(String)(String key) pure nothrow if (is(String : const(char)[])) {
337     if (key.endsWith("]")) {
338         auto t = key.findSplit("[");
339         if (t[1].length) {
340             return tuple(t[0], t[2][0..$-1]);
341         }
342     }
343     return tuple(key, typeof(key).init);
344 }
345 
346 ///
347 unittest 
348 {
349     assert(separateFromLocale("Name[ru_RU]") == tuple("Name", "ru_RU"));
350     assert(separateFromLocale("Name") == tuple("Name", string.init));
351     
352     char[] mutableString = "Hello".dup;
353     assert(separateFromLocale(mutableString) == tuple(mutableString, typeof(mutableString).init));
354 }
355 
356 /**
357  * 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).
358  * Params:
359  *  locale = original locale to match to
360  *  firstLocale = first locale
361  *  firstValue = first value
362  *  secondLocale = second locale
363  *  secondValue = second value
364  * Returns: The best alternative among two or empty string if none of alternatives match original locale.
365  * Note: value with empty locale is considered better choice than value with locale that does not match the original one.
366  */
367 @nogc @trusted auto chooseLocalizedValue(String)(
368     String locale, 
369     String firstLocale,  String firstValue, 
370     String secondLocale, String secondValue) pure nothrow
371 {   
372     const lt = parseLocaleName(locale);
373     const lt1 = parseLocaleName(firstLocale);
374     const lt2 = parseLocaleName(secondLocale);
375     
376     int score1, score2;
377     
378     if (lt.lang == lt1.lang) {
379         score1 = 1 + ((lt.country == lt1.country) ? 2 : 0 ) + ((lt.modifier == lt1.modifier) ? 1 : 0);
380     }
381     if (lt.lang == lt2.lang) {
382         score2 = 1 + ((lt.country == lt2.country) ? 2 : 0 ) + ((lt.modifier == lt2.modifier) ? 1 : 0);
383     }
384     
385     if (score1 == 0 && score2 == 0) {
386         if (firstLocale.empty && !firstValue.empty) {
387             return tuple(firstLocale, firstValue);
388         } else if (secondLocale.empty && !secondValue.empty) {
389             return tuple(secondLocale, secondValue);
390         } else {
391             return tuple(String.init, String.init);
392         }
393     }
394     
395     if (score1 >= score2) {
396         return tuple(firstLocale, firstValue);
397     } else {
398         return tuple(secondLocale, secondValue);
399     }
400 }
401 
402 ///
403 unittest
404 {
405     string locale = "ru_RU.UTF-8@jargon";
406     assert(chooseLocalizedValue(string.init, "ru_RU", "Программист", "ru@jargon", "Кодер") == tuple(string.init, string.init));
407     assert(chooseLocalizedValue(locale, "fr_FR", "Programmeur", string.init, "Programmer") == tuple(string.init, "Programmer"));
408     assert(chooseLocalizedValue(locale, string.init, "Programmer", "de_DE", "Programmierer") == tuple(string.init, "Programmer"));
409     assert(chooseLocalizedValue(locale, "fr_FR", "Programmeur", "de_DE", "Programmierer") == tuple(string.init, string.init));
410     
411     assert(chooseLocalizedValue(string.init, string.init, "Value", string.init, string.init) == tuple(string.init, "Value"));
412     assert(chooseLocalizedValue(locale, string.init, "Value", string.init, string.init) == tuple(string.init, "Value"));
413     assert(chooseLocalizedValue(locale, string.init, string.init, string.init, "Value") == tuple(string.init, "Value"));
414     
415     assert(chooseLocalizedValue(locale, "ru_RU", "Программист", "ru@jargon", "Кодер") == tuple("ru_RU", "Программист"));
416     assert(chooseLocalizedValue(locale, "ru_RU", "Программист", "ru_RU@jargon", "Кодер") == tuple("ru_RU@jargon", "Кодер"));
417     
418     assert(chooseLocalizedValue(locale, "ru", "Разработчик", "ru_RU", "Программист") == tuple("ru_RU", "Программист"));
419 }
420 
421 /**
422  * Escapes string by replacing special symbols with escaped sequences. 
423  * These symbols are: '\\' (backslash), '\n' (newline), '\r' (carriage return) and '\t' (tab).
424  * Note: 
425  *  Currently the library stores values as they were loaded from file, i.e. escaped. 
426  *  To keep things consistent you should take care about escaping the value before inserting. The library will not do it for you.
427  * Returns: Escaped string.
428  * See_Also: unescapeValue
429  */
430 @trusted String escapeValue(String)(String value) pure if (is(String : const(char)[])) {
431     return value.replace("\\", `\\`.to!String).replace("\n", `\n`.to!String).replace("\r", `\r`.to!String).replace("\t", `\t`.to!String);
432 }
433 
434 ///
435 unittest 
436 {
437     assert("a\\next\nline\top".escapeValue() == `a\\next\nline\top`); // notice how the string on the right is raw.
438     assert("a\\next\nline\top".dup.escapeValue() == `a\\next\nline\top`.dup);
439 }
440 
441 
442 /**
443  * Unescape value.
444  * Params:
445  *  value = string to unescape
446  *  pairs = pairs of escaped characters and their unescaped forms.
447  */
448 @trusted auto doUnescape(inout(char)[] value, in Tuple!(char, char)[] pairs) nothrow pure {
449     auto toReturn = appender!(typeof(value))();
450     
451     for (size_t i = 0; i < value.length; i++) {
452         if (value[i] == '\\') {
453             if (i+1 < value.length) {
454                 const char c = value[i+1];
455                 auto t = pairs.find!"a[0] == b[0]"(tuple(c,c));
456                 if (!t.empty) {
457                     toReturn.put(t.front[1]);
458                     i++;
459                     continue;
460                 }
461             }
462         }
463         toReturn.put(value[i]);
464     }
465     return toReturn.data;
466 }
467 
468 
469 /**
470  * 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).
471  * Returns: Unescaped string.
472  * See_Also: escapeValue
473  */
474 @trusted auto unescapeValue(inout(char)[] value) nothrow pure
475 {
476     static immutable Tuple!(char, char)[] pairs = [
477        tuple('s', ' '),
478        tuple('n', '\n'),
479        tuple('r', '\r'),
480        tuple('t', '\t'),
481        tuple('\\', '\\')
482     ];
483     return doUnescape(value, pairs);
484 }
485 
486 ///
487 unittest 
488 {
489     assert(`a\\next\nline\top`.unescapeValue() == "a\\next\nline\top"); // notice how the string on the left is raw.
490     assert(`\\next\nline\top`.unescapeValue() == "\\next\nline\top");
491     assert(`a\\next\nline\top`.dup.unescapeValue() == "a\\next\nline\top".dup);
492 }