1 module inilike;
2 
3 private {
4     import std.algorithm;
5     import std.array;
6     import std.conv;
7     import std.exception;
8     import std.file;
9     import std.path;
10     import std.process;
11     import std.range;
12     import std.stdio;
13     import std.string;
14     import std.traits;
15     import std.typecons;
16 }
17 
18 private alias LocaleTuple = Tuple!(string, "lang", string, "country", string, "encoding", string, "modifier");
19 private alias KeyValueTuple = Tuple!(string, "key", string, "value");
20 
21 /** Retrieves current locale probing environment variables LC_TYPE, LC_ALL and LANG (in this order)
22  * Returns: locale in posix form or empty string if could not determine locale
23  */
24 string currentLocale() @safe nothrow
25 {
26     static string cache;
27     if (cache is null) {
28         try {
29             cache = environment.get("LC_CTYPE", environment.get("LC_ALL", environment.get("LANG")));
30         }
31         catch(Exception e) {
32             
33         }
34         if (cache is null) {
35             cache = "";
36         }
37     }
38     return cache;
39 }
40 
41 /**
42  * Makes locale name based on language, country, encoding and modifier.
43  * Returns: locale name in form lang_COUNTRY.ENCODING@MODIFIER
44  */
45 string makeLocaleName(string lang, string country = null, string encoding = null, string modifier = null) pure nothrow @safe
46 {
47     return lang ~ (country.length ? "_"~country : "") ~ (encoding.length ? "."~encoding : "") ~ (modifier.length ? "@"~modifier : "");
48 }
49 
50 /**
51  * Parses locale name into the tuple of 4 values corresponding to language, country, encoding and modifier
52  * Returns: Tuple!(string, "lang", string, "country", string, "encoding", string, "modifier")
53  */
54 auto parseLocaleName(string locale) pure nothrow @nogc @trusted
55 {
56     auto modifiderSplit = findSplit(locale, "@");
57     auto modifier = modifiderSplit[2];
58     
59     auto encodongSplit = findSplit(modifiderSplit[0], ".");
60     auto encoding = encodongSplit[2];
61     
62     auto countrySplit = findSplit(encodongSplit[0], "_");
63     auto country = countrySplit[2];
64     
65     auto lang = countrySplit[0];
66     
67     return LocaleTuple(lang, country, encoding, modifier);
68 }
69 
70 /**
71  * Constructs localized key name from key and locale.
72  * Returns: localized key in form key[locale]. Automatically omits locale encoding if present.
73  */
74 string localizedKey(string key, string locale) pure nothrow @safe
75 {
76     auto t = parseLocaleName(locale);
77     if (!t.encoding.empty) {
78         locale = makeLocaleName(t.lang, t.country, null, t.modifier);
79     }
80     return key ~ "[" ~ locale ~ "]";
81 }
82 
83 /**
84  * Ditto, but constructs locale name from arguments.
85  */
86 string localizedKey(string key, string lang, string country, string modifier = null) pure nothrow @safe
87 {
88     return key ~ "[" ~ makeLocaleName(lang, country, null, modifier) ~ "]";
89 }
90 
91 /** 
92  * Separates key name into non-localized key and locale name.
93  * If key is not localized returns original key and empty string.
94  * Returns: tuple of key and locale name;
95  */
96 Tuple!(string, string) separateFromLocale(string key) nothrow @nogc @trusted {
97     if (key.endsWith("]")) {
98         auto t = key.findSplit("[");
99         if (t[1].length) {
100             return tuple(t[0], t[2][0..$-1]);
101         }
102     }
103     return tuple(key, string.init);
104 }
105 
106 /**
107  * Tells whether the character is valid for desktop entry key.
108  * Note: This does not include characters presented in locale names.
109  */
110 bool isValidKeyChar(char c) pure nothrow @nogc @safe
111 {
112     return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-';
113 }
114 
115 
116 /**
117  * Tells whethe the string is valid dekstop entry key.
118  * Note: This does not include characters presented in locale names. Use $(B separateFromLocale) to get non-localized key to pass it to this function
119  */
120 bool isValidKey(string key) pure nothrow @nogc @safe
121 {
122     if (key.empty) {
123         return false;
124     }
125     for (size_t i = 0; i<key.length; ++i) {
126         if (!key[i].isValidKeyChar()) {
127             return false;
128         }
129     }
130     return true;
131 }
132 
133 /**
134  * Tells whether the dekstop entry value presents true
135  */
136 bool isTrue(string value) pure nothrow @nogc @safe {
137     return (value == "true" || value == "1");
138 }
139 
140 /**
141  * Tells whether the desktop entry value presents false
142  */
143 bool isFalse(string value) pure nothrow @nogc @safe {
144     return (value == "false" || value == "0");
145 }
146 
147 /**
148  * Check if the desktop entry value can be interpreted as boolean value.
149  */
150 bool isBoolean(string value) pure nothrow @nogc @safe {
151     return isTrue(value) || isFalse(value);
152 }
153 /**
154  * Escapes string by replacing special symbols with escaped sequences. 
155  * These symbols are: '\\' (backslash), '\n' (newline), '\r' (carriage return) and '\t' (tab).
156  * Note: 
157  *  Currently the library stores values as they were loaded from file, i.e. escaped. 
158  *  To keep things consistent you should take care about escaping the value before inserting. The library will not do it for you.
159  * Returns: Escaped string.
160  * Example:
161 ----
162 assert("\\next\nline".escapeValue() == `\\next\nline`); // notice how the string on the right is raw.
163 ----
164  */
165 string escapeValue(string value) @trusted nothrow pure {
166     return value.replace("\\", `\\`).replace("\n", `\n`).replace("\r", `\r`).replace("\t", `\t`);
167 }
168 
169 string doUnescape(string value, in Tuple!(char, char)[] pairs) @trusted nothrow pure {
170     auto toReturn = appender!string();
171     
172     for (size_t i = 0; i < value.length; i++) {
173         if (value[i] == '\\') {
174             if (i < value.length - 1) {
175                 char c = value[i+1];
176                 auto t = pairs.find!"a[0] == b[0]"(tuple(c,c));
177                 if (!t.empty) {
178                     toReturn.put(t.front[1]);
179                     i++;
180                     continue;
181                 }
182             }
183         }
184         toReturn.put(value[i]);
185     }
186     return toReturn.data;
187 }
188 
189 
190 /**
191  * 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).
192  * Returns: Unescaped string.
193  * Example:
194 -----
195 assert(`\\next\nline`.unescapeValue() == "\\next\nline"); // notice how the string on the left is raw.
196 ----
197  */
198 string unescapeValue(string value) @trusted nothrow pure
199 {
200     static immutable Tuple!(char, char)[] pairs = [
201        tuple('s', ' '),
202        tuple('n', '\n'),
203        tuple('r', '\r'),
204        tuple('t', '\t'),
205        tuple('\\', '\\')
206     ];
207     return doUnescape(value, pairs);
208 }
209 
210 string unescapeExec(string str) @trusted nothrow pure
211 {
212     static immutable Tuple!(char, char)[] pairs = [
213        tuple('"', '"'),
214        tuple('\'', '\''),
215        tuple('\\', '\\'),
216        tuple('>', '>'),
217        tuple('<', '<'),
218        tuple('~', '~'),
219        tuple('|', '|'),
220        tuple('&', '&'),
221        tuple(';', ';'),
222        tuple('$', '$'),
223        tuple('*', '*'),
224        tuple('?', '?'),
225        tuple('#', '#'),
226        tuple('(', '('),
227        tuple(')', ')'),
228     ];
229     return doUnescape(str, pairs);
230 }
231 
232 struct IniLikeLine
233 {
234     enum Type
235     {
236         None = 0,
237         Comment = 1,
238         KeyValue = 2,
239         GroupStart = 4
240     }
241     
242     static IniLikeLine fromComment(string comment) @safe {
243         return IniLikeLine(comment, null, Type.Comment);
244     }
245     
246     static IniLikeLine fromGroupName(string groupName) @safe {
247         return IniLikeLine(groupName, null, Type.GroupStart);
248     }
249     
250     static IniLikeLine fromKeyValue(string key, string value) @safe {
251         return IniLikeLine(key, value, Type.KeyValue);
252     }
253     
254     string comment() const @safe @nogc nothrow {
255         return _type == Type.Comment ? _first : null;
256     }
257     
258     string key() const @safe @nogc nothrow {
259         return _type == Type.KeyValue ? _first : null;
260     }
261     
262     string value() const @safe @nogc nothrow {
263         return _type == Type.KeyValue ? _second : null;
264     }
265     
266     string groupName() const @safe @nogc nothrow {
267         return _type == Type.GroupStart ? _first : null;
268     }
269     
270     Type type() const @safe @nogc nothrow {
271         return _type;
272     }
273     
274     void makeNone() @safe @nogc nothrow {
275         _type = Type.None;
276     }
277     
278 private:
279     string _first;
280     string _second;
281     Type _type = Type.None;
282 }
283 
284 final class IniLikeGroup
285 {
286 private:
287     this(string name) @safe @nogc nothrow {
288         _name = name;
289     }
290     
291 public:
292     
293     /**
294      * Returns: the value associated with the key
295      * Note: it's an error to access nonexistent value
296      */
297     string opIndex(string key) const @safe @nogc nothrow {
298         auto i = key in _indices;
299         assert(_values[*i].type == IniLikeLine.Type.KeyValue);
300         assert(_values[*i].key == key);
301         return _values[*i].value;
302     }
303     
304     /**
305      * Inserts new value or replaces the old one if value associated with key already exists.
306      * Returns: inserted/updated value
307      * Throws: $(B Exception) if key is not valid
308      * See_Also: isValidKey
309      */
310     string opIndexAssign(string value, string key) @safe {
311         enforce(separateFromLocale(key)[0].isValidKey(), "key is invalid");
312         auto pick = key in _indices;
313         if (pick) {
314             return (_values[*pick] = IniLikeLine.fromKeyValue(key, value)).value;
315         } else {
316             _indices[key] = _values.length;
317             _values ~= IniLikeLine.fromKeyValue(key, value);
318             return value;
319         }
320     }
321     /**
322      * Ditto, but also allows to specify the locale.
323      * See_Also: setLocalizedValue, localizedValue
324      */
325     string opIndexAssign(string value, string key, string locale) @safe {
326         string keyName = localizedKey(key, locale);
327         return this[keyName] = value;
328     }
329     
330     /**
331      * Tells if group contains value associated with the key.
332      */
333     bool contains(string key) const @safe @nogc nothrow {
334         return value(key) !is null;
335     }
336     
337     /**
338      * Returns: the value associated with the key, or defaultValue if group does not contain item with this key.
339      */
340     string value(string key, string defaultValue = null) const @safe @nogc nothrow {
341         auto pick = key in _indices;
342         if (pick) {
343             if(_values[*pick].type == IniLikeLine.Type.KeyValue) {
344                 assert(_values[*pick].key == key);
345                 return _values[*pick].value;
346             }
347         }
348         return defaultValue;
349     }
350     
351     /**
352      * Performs locale matching lookup as described in $(LINK2 http://standards.freedesktop.org/desktop-entry-spec/latest/ar01s04.html, Localized values for keys).
353      * If locale is null it calls currentLocale to get the locale.
354      * Returns: the localized value associated with key and locale, or defaultValue if group does not contain item with this key.
355      */
356     string localizedValue(string key, string locale = null, string defaultValue = null) const @safe nothrow {
357         if (locale is null) {
358             locale = currentLocale();
359         }
360         
361         //Any ideas how to get rid of this boilerplate and make less allocations?
362         auto t = parseLocaleName(locale);
363         auto lang = t.lang;
364         auto country = t.country;
365         auto modifier = t.modifier;
366         
367         if (lang.length) {
368             string pick;
369             
370             if (country.length && modifier.length) {
371                 pick = value(localizedKey(key, locale));
372                 if (pick !is null) {
373                     return pick;
374                 }
375             }
376             
377             if (country.length) {
378                 pick = value(localizedKey(key, lang, country));
379                 if (pick !is null) {
380                     return pick;
381                 }
382             }
383             
384             if (modifier.length) {
385                 pick = value(localizedKey(key, lang, null, modifier));
386                 if (pick !is null) {
387                     return pick;
388                 }
389             }
390             
391             pick = value(localizedKey(key, lang, null));
392             if (pick !is null) {
393                 return pick;
394             }
395         }
396         
397         return value(key, defaultValue);
398     }
399     
400     /**
401      * Same as localized version of opIndexAssign, but uses function syntax.
402      */
403     void setLocalizedValue(string key, string locale, string value) @safe {
404         this[key, locale] = value;
405     }
406     
407     /**
408      * Removes entry by key. To remove localized values use localizedKey.
409      */
410     void removeEntry(string key) @safe nothrow {
411         auto pick = key in _indices;
412         if (pick) {
413             _values[*pick].makeNone();
414         }
415     }
416     
417     /**
418      * Returns: range of Tuple!(string, "key", string, "value")
419      */
420     auto byKeyValue() const @safe @nogc nothrow {
421         return _values.filter!(v => v.type == IniLikeLine.Type.KeyValue).map!(v => KeyValueTuple(v.key, v.value));
422     }
423     
424     /**
425      * Returns: the name of group
426      */
427     string name() const @safe @nogc nothrow {
428         return _name;
429     }
430     
431     auto byLine() const {
432         return _values;
433     }
434     
435     void addComment(string comment) {
436         _values ~= IniLikeLine.fromComment(comment);
437     }
438     
439 private:
440     size_t[string] _indices;
441     IniLikeLine[] _values;
442     string _name;
443 }
444 
445 /**
446  * Exception thrown on the file read error.
447  */
448 class IniLikeException : Exception
449 {
450     this(string msg, size_t lineNumber, string file = __FILE__, size_t line = __LINE__, Throwable next = null) pure nothrow @safe {
451         super(msg, file, line, next);
452         _lineNumber = lineNumber;
453     }
454     
455     ///Number of line in desktop file where the exception occured, starting from 1. Don't be confused with $(B line) property of $(B Throwable).
456     size_t lineNumber() const nothrow @safe @nogc {
457         return _lineNumber;
458     }
459     
460 private:
461     size_t _lineNumber;
462 }
463 
464 auto iniLikeFileReader(string fileName)
465 {
466     return iniLikeRangeReader(File(fileName, "r").byLine().map!(s => s.idup));
467 }
468 
469 auto iniLikeStringReader(string contents)
470 {
471     return iniLikeRangeReader(contents.splitLines());
472 }
473 
474 auto iniLikeRangeReader(Range)(Range byLine)
475 {
476     return byLine.map!(function(string line) {
477         line = strip(line);
478         if (line.empty || line.startsWith("#")) {
479             return IniLikeLine.fromComment(line);
480         } else if (line.startsWith("[") && line.endsWith("]")) {
481             return IniLikeLine.fromGroupName(line[1..$-1]);
482         } else {
483             auto t = line.findSplit("=");
484             auto key = t[0].stripRight();
485             auto value = t[2].stripLeft();
486             
487             if (t[1].length) {
488                 return IniLikeLine.fromKeyValue(key, value);
489             } else {
490                 return IniLikeLine();
491             }         
492         }
493     });
494 }
495 
496 class IniLikeFile
497 {
498 public:
499     ///Flags to manage .ini like file reading
500     enum ReadOptions
501     {
502         noOptions = 0, /// Read all groups and skip comments and empty lines.
503         firstGroupOnly = 1, /// Ignore other groups than the first one.
504         preserveComments = 2 /// Preserve comments and empty lines. Use this when you want to preserve them across writing.
505     }
506     
507     /**
508      * Reads desktop file from file.
509      * Throws:
510      *  $(B ErrnoException) if file could not be opened.
511      *  $(B IniLikeException) if error occured while reading the file.
512      */
513     static IniLikeFile loadFromFile(string fileName, ReadOptions options = ReadOptions.noOptions) @trusted {
514         return new IniLikeFile(iniLikeFileReader(fileName), options, fileName);
515     }
516     
517     /**
518      * Reads desktop file from string.
519      * Throws:
520      *  $(B IniLikeException) if error occured while parsing the contents.
521      */
522     static IniLikeFile loadFromString(string contents, ReadOptions options = ReadOptions.noOptions, string fileName = null) @trusted {
523         return new IniLikeFile(iniLikeStringReader(contents), options, fileName);
524     }
525     
526     this() {
527         
528     }
529     
530     this(Range)(Range byLine, ReadOptions options = ReadOptions.noOptions, string fileName = null) @trusted
531     {
532         size_t lineNumber = 0;
533         IniLikeGroup currentGroup;
534         
535         try {
536             foreach(line; byLine)
537             {
538                 lineNumber++;
539                 final switch(line.type)
540                 {
541                     case IniLikeLine.Type.Comment:
542                     {
543                         if (options & ReadOptions.preserveComments) {
544                             if (currentGroup is null) {
545                                 addFirstComment(line.comment);
546                             } else {
547                                 currentGroup.addComment(line.comment);
548                             }
549                         }
550                     }
551                     break;
552                     case IniLikeLine.Type.GroupStart:
553                     {
554                         enforce(line.groupName.length, "empty group name");
555                         enforce(group(line.groupName) is null, "group is defined more than once");
556                         
557                         currentGroup = addGroup(line.groupName);
558                         
559                         if (options & ReadOptions.firstGroupOnly) {
560                             break;
561                         }
562                     }
563                     break;
564                     case IniLikeLine.Type.KeyValue:
565                     {
566                         enforce(currentGroup, "met key-value pair before any group");
567                         currentGroup[line.key] = line.value;
568                     }
569                     break;
570                     case IniLikeLine.Type.None:
571                     {
572                         throw new Exception("not key-value pair, nor group start nor comment");
573                     }
574                 }
575             }
576             
577             _fileName = fileName;
578         }
579         catch (Exception e) {
580             throw new IniLikeException(e.msg, lineNumber, e.file, e.line, e.next);
581         }
582     }
583     
584     /**
585      * Returns: IniLikeGroup instance associated with groupName or $(B null) if not found.
586      */
587     inout(IniLikeGroup) group(string groupName) @safe @nogc nothrow inout {
588         auto pick = groupName in _groupIndices;
589         if (pick) {
590             return _groups[*pick];
591         }
592         return null;
593     }
594     
595     /**
596      * Creates new group usin groupName.
597      * Returns: newly created instance of IniLikeGroup.
598      * Throws: Exception if group with such name already exists or groupName is empty.
599      */
600     IniLikeGroup addGroup(string groupName) @safe {
601         enforce(groupName.length, "group name is empty");
602         
603         auto iniLikeGroup = new IniLikeGroup(groupName);
604         enforce(group(groupName) is null, "group already exists");
605         _groupIndices[groupName] = _groups.length;
606         _groups ~= iniLikeGroup;
607         
608         return iniLikeGroup;
609     }
610     
611     /**
612      * Removes group by name.
613      */
614     void removeGroup(string groupName) @safe nothrow {
615         auto pick = groupName in _groupIndices;
616         if (pick) {
617             _groups[*pick] = null;
618         }
619     }
620     
621     /**
622      * Range of groups in order how they were defined in file.
623      */
624     auto byGroup() inout {
625         return _groups[];
626     }
627     
628     /**
629      * Saves object to file using .ini like format.
630      * Throws: ErrnoException if the file could not be opened or an error writing to the file occured.
631      */
632     void saveToFile(string fileName) const {
633         auto f = File(fileName, "w");
634         void dg(string line) {
635             f.writeln(line);
636         }
637         save(&dg);
638     }
639     
640     /**
641      * Saves object to string using .ini like format.
642      */
643     string saveToString() const {
644         auto a = appender!(string[])();
645         void dg(string line) {
646             a.put(line);
647         }
648         save(&dg);
649         return a.data.join("\n");
650     }
651     
652     private alias SaveDelegate = void delegate(string);
653     
654     private void save(SaveDelegate sink) const {
655         foreach(line; firstComments()) {
656             sink(line);
657         }
658         
659         foreach(group; byGroup()) {
660             sink("[" ~ group.name ~ "]");
661             foreach(line; group._values) {
662                 if (line.type == IniLikeLine.Type.Comment) {
663                     sink(line.comment);
664                 } else if (line.type == IniLikeLine.Type.KeyValue) {
665                     sink(line.key ~ "=" ~ line.value);
666                 }
667             }
668         }
669     }
670     
671     /**
672      * Returns: file name as was specified on the object creation.
673      */
674     string fileName() @safe @nogc nothrow const {
675         return  _fileName;
676     }
677     
678 protected:
679     auto firstComments() const nothrow @safe @nogc {
680         return _firstComments;
681     }
682     
683     void addFirstComment(string line) nothrow @safe {
684         _firstComments ~= line;
685     }
686     
687 private:
688     string _fileName;
689     size_t[string] _groupIndices;
690     IniLikeGroup[] _groups;
691     string[] _firstComments;
692 }
693 
694 unittest
695 {
696     //Test locale-related functions
697     assert(makeLocaleName("ru", "RU") == "ru_RU");
698     assert(makeLocaleName("ru", "RU", "UTF-8") == "ru_RU.UTF-8");
699     assert(makeLocaleName("ru", "RU", "UTF-8", "mod") == "ru_RU.UTF-8@mod");
700     assert(makeLocaleName("ru", null, null, "mod") == "ru@mod");
701     
702     assert(parseLocaleName("ru_RU.UTF-8@mod") == tuple("ru", "RU", "UTF-8", "mod"));
703     assert(parseLocaleName("ru@mod") == tuple("ru", string.init, string.init, "mod"));
704     assert(parseLocaleName("ru_RU") == tuple("ru", "RU", string.init, string.init));
705     
706     assert(localizedKey("Name", "ru_RU") == "Name[ru_RU]");
707     assert(localizedKey("Name", "ru_RU.UTF-8") == "Name[ru_RU]");
708     assert(localizedKey("Name", "ru", "RU") == "Name[ru_RU]");
709     
710     assert(separateFromLocale("Name[ru_RU]") == tuple("Name", "ru_RU"));
711     assert(separateFromLocale("Name") == tuple("Name", string.init));
712     
713     //Test locale matching lookup
714     auto group = new IniLikeGroup("Entry");
715     assert(group.name == "Entry"); 
716     group["Name"] = "Programmer";
717     group["Name[ru_RU]"] = "Разработчик";
718     group["Name[ru@jargon]"] = "Кодер";
719     group["Name[ru]"] = "Программист";
720     group["GenericName"] = "Program";
721     group["GenericName[ru]"] = "Программа";
722     assert(group["Name"] == "Programmer");
723     assert(group.localizedValue("Name", "ru@jargon") == "Кодер");
724     assert(group.localizedValue("Name", "ru_RU@jargon") == "Разработчик");
725     assert(group.localizedValue("Name", "ru") == "Программист");
726     assert(group.localizedValue("Name", "nonexistent locale") == "Programmer");
727     assert(group.localizedValue("GenericName", "ru_RU") == "Программа");
728     
729     //Test escaping and unescaping
730     assert("\\next\nline".escapeValue() == `\\next\nline`);
731     assert(`\\next\nline`.unescapeValue() == "\\next\nline");
732 }