1 /**
2  * Reading, writing and executing .desktop file
3  * 
4  * License: $(LINK2 http://www.boost.org/LICENSE_1_0.txt, Boost License 1.0).
5  * See_Also: $(LINK2 http://standards.freedesktop.org/desktop-entry-spec/latest/index.html, Desktop Entry Specification)
6  */
7 
8 module desktopfile;
9 
10 private {
11     import std.algorithm;
12     import std.array;
13     import std.conv;
14     import std.exception;
15     import std.file;
16     import std.path;
17     import std.process;
18     import std.range;
19     import std.stdio;
20     import std.string;
21     import std.traits;
22     import std.typecons;
23 }
24 
25 /**
26  * Exception thrown when error occures during the .desktop file read.
27  */
28 class DesktopFileException : Exception
29 {
30     this(string msg, size_t lineNumber, string file = __FILE__, size_t line = __LINE__, Throwable next = null) pure nothrow @safe {
31         super(msg, file, line, next);
32         _lineNumber = lineNumber;
33     }
34     
35     ///Number of line in desktop file where the exception occured, starting from 1. Don't be confused with $(B line) property of $(B Throwable).
36     size_t lineNumber() const {
37         return _lineNumber;
38     }
39     
40 private:
41     size_t _lineNumber;
42 }
43 
44 private alias LocaleTuple = Tuple!(string, "lang", string, "country", string, "encoding", string, "modifier");
45 private alias KeyValueTuple = Tuple!(string, "key", string, "value");
46 
47 /** Retrieves current locale probing environment variables LC_TYPE, LC_ALL and LANG (in this order)
48  * Returns: locale in posix form or empty string if could not determine locale
49  */
50 string currentLocale() @safe nothrow
51 {
52     static string cache;
53     if (cache is null) {
54         try {
55             cache = environment.get("LC_CTYPE", environment.get("LC_ALL", environment.get("LANG")));
56         }
57         catch(Exception e) {
58             
59         }
60         if (cache is null) {
61             cache = "";
62         }
63     }
64     return cache;
65 }
66 
67 /**
68  * Returns: locale name in form lang_COUNTRY.ENCODING@MODIFIER
69  */
70 string makeLocaleName(string lang, string country = null, string encoding = null, string modifier = null) pure nothrow @safe
71 {
72     return lang ~ (country.length ? "_"~country : "") ~ (encoding.length ? "."~encoding : "") ~ (modifier.length ? "@"~modifier : "");
73 }
74 
75 /**
76  * Parses locale name into the tuple of 4 values corresponding to language, country, encoding and modifier
77  * Returns: Tuple!(string, "lang", string, "country", string, "encoding", string, "modifier")
78  */
79 auto parseLocaleName(string locale) pure nothrow @nogc @trusted
80 {
81     auto modifiderSplit = findSplit(locale, "@");
82     auto modifier = modifiderSplit[2];
83     
84     auto encodongSplit = findSplit(modifiderSplit[0], ".");
85     auto encoding = encodongSplit[2];
86     
87     auto countrySplit = findSplit(encodongSplit[0], "_");
88     auto country = countrySplit[2];
89     
90     auto lang = countrySplit[0];
91     
92     return LocaleTuple(lang, country, encoding, modifier);
93 }
94 
95 /**
96  * Returns: localized key in form key[locale]. Automatically omits locale encoding if present.
97  */
98 string localizedKey(string key, string locale) pure nothrow @safe
99 {
100     auto t = parseLocaleName(locale);
101     if (!t.encoding.empty) {
102         locale = makeLocaleName(t.lang, t.country, null, t.modifier);
103     }
104     return key ~ "[" ~ locale ~ "]";
105 }
106 
107 /**
108  * Ditto, but constructs locale name from arguments.
109  */
110 string localizedKey(string key, string lang, string country, string modifier = null) pure nothrow @safe
111 {
112     return key ~ "[" ~ makeLocaleName(lang, country, null, modifier) ~ "]";
113 }
114 
115 /** 
116  * Separates key name into non-localized key and locale name.
117  * If key is not localized returns original key and empty string.
118  * Returns: tuple of key and locale name;
119  */
120 Tuple!(string, string) separateFromLocale(string key) nothrow @nogc @trusted {
121     if (key.endsWith("]")) {
122         auto t = key.findSplit("[");
123         if (t[1].length) {
124             return tuple(t[0], t[2][0..$-1]);
125         }
126     }
127     return tuple(key, string.init);
128 }
129 
130 /**
131  * Tells whether the character is valid for desktop entry key.
132  * Note: This does not include characters presented in locale names.
133  */
134 bool isValidKeyChar(char c) pure nothrow @nogc @safe
135 {
136     return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-';
137 }
138 
139 
140 /**
141  * Tells whethe the string is valid dekstop entry key.
142  * Note: This does not include characters presented in locale names. Use $(B separateFromLocale) to get non-localized key to pass it to this function
143  */
144 bool isValidKey(string key) pure nothrow @nogc @safe
145 {
146     if (key.empty) {
147         return false;
148     }
149     for (size_t i = 0; i<key.length; ++i) {
150         if (!key[i].isValidKeyChar()) {
151             return false;
152         }
153     }
154     return true;
155 }
156 
157 /**
158  * Tells whether the dekstop entry value presents true
159  */
160 bool isTrue(string value) pure nothrow @nogc @safe {
161     return (value == "true" || value == "1");
162 }
163 
164 /**
165  * Tells whether the desktop entry value presents false
166  */
167 bool isFalse(string value) pure nothrow @nogc @safe {
168     return (value == "false" || value == "0");
169 }
170 
171 /**
172  * Check if the desktop entry value can be interpreted as boolean value.
173  */
174 bool isBoolean(string value) pure nothrow @nogc @safe {
175     return isTrue(value) || isFalse(value);
176 }
177 
178 string escapeValue(string value) @trusted nothrow pure {
179     return value.replace("\\", `\\`).replace("\n", `\n`).replace("\r", `\r`).replace("\t", `\t`);
180 }
181 
182 string doUnescape(string value, in Tuple!(char, char)[] pairs) @trusted nothrow pure {
183     auto toReturn = appender!string();
184     
185     for (size_t i = 0; i < value.length; i++) {
186         if (value[i] == '\\') {
187             if (i < value.length - 1) {
188                 char c = value[i+1];
189                 auto t = pairs.find!"a[0] == b[0]"(tuple(c,c));
190                 if (!t.empty) {
191                     toReturn.put(t.front[1]);
192                     i++;
193                     continue;
194                 }
195             }
196         }
197         toReturn.put(value[i]);
198     }
199     return toReturn.data;
200 }
201 
202 string unescapeValue(string value) @trusted nothrow pure
203 {
204     static immutable Tuple!(char, char)[] pairs = [
205        tuple('s', ' '),
206        tuple('n', '\n'),
207        tuple('r', '\r'),
208        tuple('t', '\t'),
209        tuple('\\', '\\')
210     ];
211     return doUnescape(value, pairs);
212 }
213 
214 string unescapeExec(string str) @trusted nothrow pure
215 {
216     static immutable Tuple!(char, char)[] pairs = [
217        tuple('"', '"'),
218        tuple('\'', '\''),
219        tuple('\\', '\\'),
220        tuple('>', '>'),
221        tuple('<', '<'),
222        tuple('~', '~'),
223        tuple('|', '|'),
224        tuple('&', '&'),
225        tuple(';', ';'),
226        tuple('$', '$'),
227        tuple('*', '*'),
228        tuple('?', '?'),
229        tuple('#', '#'),
230        tuple('(', '('),
231        tuple(')', ')'),
232     ];
233     return doUnescape(str, pairs);
234 }
235 
236 /**
237  * Checks if the program exists and is executable. 
238  * If the programPath is not an absolute path, the file is looked up in the $PATH environment variable.
239  * This function is defined only on Posix.
240  */
241 version(Posix)
242 {
243     bool checkTryExec(string programPath) @safe {
244         bool isExecutable(string filePath) @trusted nothrow {
245             import core.sys.posix.unistd;
246             return access(toStringz(filePath), X_OK) == 0;
247         }
248         
249         if (programPath.isAbsolute()) {
250             return isExecutable(programPath);
251         }
252         
253         foreach(path; environment.get("PATH").splitter(':')) {
254             if (isExecutable(buildPath(path, programPath))) {
255                 return true;
256             }
257         }
258         return false;
259     }
260 }
261 
262 
263 /**
264  * This class represents the group in the desktop file. 
265  * You can create and use instances of this class only in the context of $(B DesktopFile) instance.
266  */
267 final class DesktopGroup
268 {
269 private:
270     static struct Line
271     {
272         enum Type
273         {
274             None, 
275             Comment, 
276             KeyValue
277         }
278         
279         this(string comment) @safe {
280             _first = comment;
281             _type = Type.Comment;
282         }
283         
284         this(string key, string value) @safe {
285             _first = key;
286             _second = value;
287             _type = Type.KeyValue;
288         }
289         
290         string comment() @safe @nogc nothrow const {
291             return _first;
292         }
293         
294         string key() @safe @nogc nothrow const {
295             return _first;
296         }
297         
298         string value() @safe @nogc nothrow const {
299             return _second;
300         }
301         
302         Type type() @safe @nogc nothrow const {
303             return _type;
304         }
305         
306         void makeNone() @safe @nogc nothrow {
307             _type = Type.None;
308         }
309         
310     private:
311         Type _type = Type.None;
312         string _first;
313         string _second;
314     }
315     
316     this(string name) @safe @nogc nothrow {
317         _name = name;
318     }
319     
320 public:
321     
322     /**
323      * Returns: the value associated with the key
324      * Note: it's an error to access nonexistent value
325      */
326     string opIndex(string key) const @safe @nogc nothrow {
327         auto i = key in _indices;
328         assert(_values[*i].type == Line.Type.KeyValue);
329         assert(_values[*i].key == key);
330         return _values[*i].value;
331     }
332     
333     /**
334      * Inserts new value or replaces the old one if value associated with key already exists.
335      * Returns: inserted/updated value
336      * Throws: $(B Exception) if key is not valid
337      * See_Also: isValidKey
338      */
339     string opIndexAssign(string value, string key) @safe {
340         enforce(separateFromLocale(key)[0].isValidKey(), "key is invalid");
341         auto pick = key in _indices;
342         if (pick) {
343             return (_values[*pick] = Line(key, value)).value;
344         } else {
345             _indices[key] = _values.length;
346             _values ~= Line(key, value);
347             return value;
348         }
349     }
350     /**
351      * Ditto, but also allows to specify the locale.
352      * See_Also: setLocalizedValue, localizedValue
353      */
354     string opIndexAssign(string value, string key, string locale) @safe {
355         string keyName = localizedKey(key, locale);
356         return this[keyName] = value;
357     }
358     
359     /**
360      * Tells if group contains value associated with the key.
361      */
362     bool contains(string key) const @safe @nogc nothrow {
363         return value(key) !is null;
364     }
365     
366     /**
367      * Returns: the value associated with the key, or defaultValue if group does not contain item with this key.
368      */
369     string value(string key, string defaultValue = null) const @safe @nogc nothrow {
370         auto pick = key in _indices;
371         if (pick) {
372             if(_values[*pick].type == Line.Type.KeyValue) {
373                 assert(_values[*pick].key == key);
374                 return _values[*pick].value;
375             }
376         }
377         return defaultValue;
378     }
379     
380     /**
381      * Performs locale matching lookup as described in $(LINK2 http://standards.freedesktop.org/desktop-entry-spec/latest/ar01s04.html, Localized values for keys).
382      * If locale is null it calls currentLocale to get the locale.
383      * Returns: the localized value associated with key and locale, or defaultValue if group does not contain item with this key.
384      */
385     string localizedValue(string key, string locale = null, string defaultValue = null) const @safe nothrow {
386         if (locale is null) {
387             locale = currentLocale();
388         }
389         
390         //Any ideas how to get rid of this boilerplate and make less allocations?
391         auto t = parseLocaleName(locale);
392         auto lang = t.lang;
393         auto country = t.country;
394         auto modifier = t.modifier;
395         
396         if (lang.length) {
397             string pick;
398             
399             if (country.length && modifier.length) {
400                 pick = value(localizedKey(key, locale));
401                 if (pick !is null) {
402                     return pick;
403                 }
404             }
405             
406             if (country.length) {
407                 pick = value(localizedKey(key, lang, country));
408                 if (pick !is null) {
409                     return pick;
410                 }
411             }
412             
413             if (modifier.length) {
414                 pick = value(localizedKey(key, lang, null, modifier));
415                 if (pick !is null) {
416                     return pick;
417                 }
418             }
419             
420             pick = value(localizedKey(key, lang, null));
421             if (pick !is null) {
422                 return pick;
423             }
424         }
425         
426         return value(key, defaultValue);
427     }
428     
429     /**
430      * Same as localized version of opIndexAssign, but uses function syntax.
431      */
432     void setLocalizedValue(string key, string locale, string value) @safe {
433         this[key, locale] = value;
434     }
435     
436     /**
437      * Removes entry by key. To remove localized values use localizedKey.
438      */
439     void removeEntry(string key) @safe nothrow {
440         auto pick = key in _indices;
441         if (pick) {
442             _values[*pick].makeNone();
443         }
444     }
445     
446     /**
447      * Returns: range of Tuple!(string, "key", string, "value")
448      */
449     auto byKeyValue() const @safe @nogc nothrow {
450         return _values.filter!(v => v.type == Line.Type.KeyValue).map!(v => KeyValueTuple(v.key, v.value));
451     }
452     
453     /**
454      * Returns: the name of group
455      */
456     string name() const @safe @nogc nothrow {
457         return _name;
458     }
459     
460 private:
461     void addComment(string comment) {
462         _values ~= Line(comment);
463     }
464     
465     size_t[string] _indices;
466     Line[] _values;
467     string _name;
468 }
469 
470 /**
471  * Represents .desktop file.
472  * 
473  */
474 final class DesktopFile
475 {
476 public:
477     enum Type
478     {
479         Unknown, ///Desktop entry is unknown type
480         Application, ///Desktop describes application
481         Link, ///Desktop describes URL
482         Directory ///Desktop entry describes directory settings
483     }
484     
485     enum ReadOptions
486     {
487         noOptions = 0, /// Read all groups and skip comments and empty lines
488         desktopEntryOnly = 1, /// Ignore other groups than Desktop Entry
489         preserveComments = 2 /// Preserve comments and empty lines
490     }
491     
492     /**
493      * Reads desktop file from file.
494      * Throws:
495      *  $(B ErrnoException) if file could not be opened.
496      *  $(B DesktopFileException) if error occured while reading the file.
497      */
498     static DesktopFile loadFromFile(string fileName, ReadOptions options = ReadOptions.noOptions) @trusted {
499         auto f = File(fileName, "r");
500         return new DesktopFile(f.byLine().map!(s => s.idup), options, fileName);
501     }
502     
503     /**
504      * Reads desktop file from string.
505      * Throws:
506      *  $(B DesktopFileException) if error occured while parsing the contents.
507      */
508     static DesktopFile loadFromString(string contents, ReadOptions options = ReadOptions.noOptions, string fileName = null) @trusted {
509         return new DesktopFile(contents.splitLines(), options, fileName);
510     }
511     
512     private this(Range)(Range byLine, ReadOptions options, string fileName) @trusted
513     {   
514         size_t lineNumber = 0;
515         string currentGroup;
516         
517         try {
518             foreach(line; byLine) {
519                 lineNumber++;
520                 line = strip(line);
521                 
522                 if (line.empty || line.startsWith("#")) {
523                     if (options & ReadOptions.preserveComments) {
524                         if (currentGroup is null) {
525                             firstLines ~= line;
526                         } else {
527                             group(currentGroup).addComment(line);
528                         }
529                     }
530                     
531                     continue;
532                 }
533                 
534                 if (line.startsWith("[") && line.endsWith("]")) {
535                     string groupName = line[1..$-1];
536                     enforce(groupName.length, "empty group name");
537                     enforce(group(groupName) is null, "group is defined more than once");
538                     
539                     if (currentGroup is null) {
540                         enforce(groupName == "Desktop Entry", "the first group must be Desktop Entry");
541                     } else if (options & ReadOptions.desktopEntryOnly) {
542                         break;
543                     }
544                     
545                     addGroup(groupName);
546                     currentGroup = groupName;
547                 } else {
548                     auto t = line.findSplit("=");
549                     t[0] = t[0].stripRight();
550                     t[2] = t[2].stripLeft();
551                     
552                     enforce(t[1].length, "not key-value pair, nor group start nor comment");
553                     enforce(currentGroup.length, "met key-value pair before any group");
554                     assert(group(currentGroup) !is null, "logic error: currentGroup is not in _groups");
555                     
556                     group(currentGroup)[t[0]] = t[2];
557                 }
558             }
559             
560             _desktopEntry = group("Desktop Entry");
561             enforce(_desktopEntry !is null, "Desktop Entry group is missing");
562             _fileName = fileName;
563         }
564         catch (Exception e) {
565             throw new DesktopFileException(e.msg, lineNumber, e.file, e.line, e.next);
566         }
567     }
568     
569     /**
570      * Constructs DesktopFile with "Desktop Entry" group and Version set to 1.0
571      */
572     this() {
573         _desktopEntry = addGroup("Desktop Entry");
574         desktopEntry()["Version"] = "1.0";
575     }
576     
577     /**
578      * Returns: file name as was specified on the object creating
579      */
580     string fileName() @safe @nogc nothrow const {
581         return  _fileName;
582     }
583     
584     /**
585      * Saves object to file using Desktop File format.
586      * Throws: ErrnoException if the file could not be opened or an error writing to the file occured.
587      */
588     void saveToFile(string fileName) const {
589         auto f = File(fileName, "w");
590         void dg(string line) {
591             f.writeln(line);
592         }
593         save(&dg);
594     }
595     
596     /**
597      * Saves object to string using Desktop File format.
598      */
599     string saveToString() const {
600         auto a = appender!(string[])();
601         void dg(string line) {
602             a.put(line);
603         }
604         save(&dg);
605         return a.data.join("\n");
606     }
607     
608     private alias SaveDelegate = void delegate(string);
609     
610     private void save(SaveDelegate sink) const {
611         foreach(line; firstLines) {
612             sink(line);
613         }
614         
615         foreach(group; byGroup()) {
616             sink("[" ~ group.name ~ "]");
617             foreach(line; group._values) {
618                 if (line.type == DesktopGroup.Line.Type.Comment) {
619                     sink(line.comment);
620                 } else if (line.type == DesktopGroup.Line.Type.KeyValue) {
621                     sink(line.key ~ "=" ~ line.value);
622                 }
623             }
624         }
625     }
626     
627     /**
628      * Returns: DesktopGroup instance associated with groupName or $(B null) if not found.
629      */
630     inout(DesktopGroup) group(string groupName) @safe @nogc nothrow inout {
631         auto pick = groupName in _groupIndices;
632         if (pick) {
633             return _groups[*pick];
634         }
635         return null;
636     }
637     
638     /**
639      * Creates new group usin groupName.
640      * Returns: newly created instance of DesktopGroup.
641      * Throws: Exception if group with such name already exists or groupName is empty.
642      */
643     DesktopGroup addGroup(string groupName) @safe {
644         enforce(groupName.length, "group name is empty");
645         
646         auto desktopGroup = new DesktopGroup(groupName);
647         enforce(group(groupName) is null, "group already exists");
648         _groupIndices[groupName] = _groups.length;
649         _groups ~= desktopGroup;
650         
651         return desktopGroup;
652     }
653     
654     /**
655      * Range of groups in order how they are defined in .desktop file. The first group is always $(B Desktop Entry).
656      */
657     auto byGroup() const {
658         return _groups[];
659     }
660     
661     /**
662      * Returns: Type of desktop entry.
663      */
664     Type type() const @safe @nogc nothrow {
665         string t = value("Type");
666         if (t.length) {
667             if (t == "Application") {
668                 return Type.Application;
669             } else if (t == "Link") {
670                 return Type.Link;
671             } else if (t == "Directory") {
672                 return Type.Directory;
673             }
674         }
675         if (_fileName.extension == ".directory") {
676             return Type.Directory;
677         }
678         
679         return Type.Unknown;
680     }
681     /// Sets "Type" field to type
682     Type type(Type t) @safe {
683         final switch(t) {
684             case Type.Application:
685                 this["Type"] = "Application";
686                 break;
687             case Type.Link:
688                 this["Type"] = "Link";
689                 break;
690             case Type.Directory:
691                 this["Type"] = "Directory";
692                 break;
693             case Type.Unknown:
694                 break;
695         }
696         return t;
697     }
698     
699     /**
700      * Specific name of the application, for example "Mozilla".
701      * Returns: the value associated with "Name" key.
702      */
703     string name() const @safe @nogc nothrow {
704         return value("Name");
705     }
706     ///ditto, but returns localized value.
707     string localizedName(string locale = null) const @safe nothrow {
708         return localizedValue("Name");
709     }
710     
711     /**
712      * Generic name of the application, for example "Web Browser".
713      * Returns: the value associated with "GenericName" key.
714      */
715     string genericName() const @safe @nogc nothrow {
716         return value("GenericName");
717     }
718     ///ditto, but returns localized value.
719     string localizedGenericName(string locale = null) const @safe nothrow {
720         return localizedValue("GenericName");
721     }
722     
723     /**
724      * Tooltip for the entry, for example "View sites on the Internet".
725      * Returns: the value associated with "Comment" key.
726      */
727     string comment() const @safe @nogc nothrow {
728         return value("Comment");
729     }
730     ///ditto, but returns localized value.
731     string localizedComment(string locale = null) const @safe nothrow {
732         return localizedValue("Comment");
733     }
734     
735     /** 
736      * Returns: the value associated with "Exec" key.
737      * Note: don't use this to start the program. Consider using expandExecString or startApplication instead.
738      */
739     string execString() const @safe @nogc nothrow {
740         return value("Exec");
741     }
742     
743     
744     /**
745      * Returns: the value associated with "TryExec" key.
746      */
747     string tryExecString() const @safe @nogc nothrow {
748         return value("TryExec");
749     }
750     
751     /**
752      * Returns: the value associated with "Icon" key. If not found it also tries "X-Window-Icon".
753      * Note: this function returns Icon as it's defined in .desktop file. It does not provides any lookup of actual icon file on the system.
754      */
755     string iconName() const @safe @nogc nothrow {
756         string iconPath = value("Icon");
757         if (iconPath is null) {
758             iconPath = value("X-Window-Icon");
759         }
760         return iconPath;
761     }
762     
763     /**
764      * Returns: the value associated with "NoDisplay" key converted to bool using isTrue.
765      */
766     bool noDisplay() const @safe @nogc nothrow {
767         return isTrue(value("NoDisplay"));
768     }
769     
770     /**
771      * Returns: the value associated with "Hidden" key converted to bool using isTrue.
772      */
773     bool hidden() const @safe @nogc nothrow {
774         return isTrue(value("Hidden"));
775     }
776     
777     /**
778      * The working directory to run the program in.
779      * Returns: the value associated with "Path" key.
780      */
781     string workingDirectory() const @safe @nogc nothrow {
782         return value("Path");
783     }
784     
785     /**
786      * Whether the program runs in a terminal window.
787      * Returns: the value associated with "Hidden" key converted to bool using isTrue.
788      */
789     bool terminal() const @safe @nogc nothrow {
790         return isTrue(value("Terminal"));
791     }
792     /// Sets "Terminal" field to true or false.
793     bool terminal(bool t) @safe {
794         this["Terminal"] = t ? "true" : "false";
795         return t;
796     }
797     
798     /**
799      * Some keys can have multiple values, separated by semicolon. This function helps to parse such kind of strings to the range.
800      * Returns: the range of multiple values.
801      */
802     static auto splitValues(string values) @trusted {
803         return values.splitter(';').filter!(s => s.length != 0);
804     }
805     
806     /**
807      * Join range of multiple values into a string using semicolon as separator. Adds trailing semicolon.
808      * If range is empty, empty string is returned.
809      */
810     static @trusted string joinValues(Range)(Range values) if (isInputRange!Range && isSomeString!(ElementType!Range)) {
811         auto result = values.filter!( s => s.length != 0 ).joiner(";");
812         if (result.empty) {
813             return null;
814         } else {
815             return text(result) ~ ";";
816         }
817     }
818     
819     /**
820      * Categories this program belongs to.
821      * Returns: the range of multiple values associated with "Categories" key.
822      */
823     auto categories() const @safe {
824         return splitValues(value("Categories"));
825     }
826     
827     /**
828      * Sets the list of values for the "Categories" list.
829      */
830     @safe void categories(Range)(Range values) if (isInputRange!Range && isSomeString!(ElementType!Range)) {
831         this["Categories"] = joinValues(values);
832     }
833     
834     /**
835      * A list of strings which may be used in addition to other metadata to describe this entry.
836      * Returns: the range of multiple values associated with "Keywords" key.
837      */
838     auto keywords() const @safe {
839         return splitValues(value("Keywords"));
840     }
841     
842     /**
843      * Sets the list of values for the "Keywords" list.
844      */
845     @safe void keywords(Range)(Range values) if (isInputRange!Range && isSomeString!(ElementType!Range)) {
846         this["Keywords"] = joinValues(values);
847     }
848     
849     /**
850      * The MIME type(s) supported by this application.
851      * Returns: the range of multiple values associated with "MimeType" key.
852      */
853     auto mimeTypes() const @safe {
854         return splitValues(value("MimeType"));
855     }
856     
857     /**
858      * Sets the list of values for the "MimeType" list.
859      */
860     @safe void mimeTypes(Range)(Range values) if (isInputRange!Range && isSomeString!(ElementType!Range)) {
861         this["MimeType"] = joinValues(values);
862     }
863     
864     /**
865      * A list of strings identifying the desktop environments that should display a given desktop entry.
866      * Returns: the range of multiple values associated with "OnlyShowIn" key.
867      */
868     auto onlyShowIn() const @safe {
869         return splitValues(value("OnlyShowIn"));
870     }
871     
872     /**
873      * A list of strings identifying the desktop environments that should not display a given desktop entry.
874      * Returns: the range of multiple values associated with "NotShowIn" key.
875      */
876     auto notShowIn() const @safe {
877         return splitValues(value("NotShowIn"));
878     }
879     
880     /**
881      * Returns: instance of "Desktop Entry" group.
882      * Note: usually you don't need to call this function since you can rely on alias this.
883      */
884     inout(DesktopGroup) desktopEntry() @safe @nogc nothrow inout {
885         return _desktopEntry;
886     }
887     
888     
889     /**
890      * This alias allows to call functions related to "Desktop Entry" group without need to call desktopEntry explicitly.
891      */
892     alias desktopEntry this;
893     
894     /**
895      * Expands Exec string into the array of command line arguments to use to start the program.
896      */
897     string[] expandExecString(in string[] urls = null) const @safe
898     {   
899         string[] toReturn;
900         auto execStr = execString().unescapeExec(); //add unquoting
901         
902         foreach(token; execStr.split) {
903             if (token == "%f") {
904                 if (urls.length) {
905                     toReturn ~= urls.front;
906                 }
907             } else if (token == "%F") {
908                 toReturn ~= urls;
909             } else if (token == "%u") {
910                 if (urls.length) {
911                     toReturn ~= urls.front;
912                 }
913             } else if (token == "%U") {
914                 toReturn ~= urls;
915             } else if (token == "%i") {
916                 string iconStr = iconName();
917                 if (iconStr.length) {
918                     toReturn ~= "--icon";
919                     toReturn ~= iconStr;
920                 }
921             } else if (token == "%c") {
922                 toReturn ~= localizedValue("Name");
923             } else if (token == "%k") {
924                 toReturn ~= fileName();
925             } else if (token == "%d" || token == "%D" || token == "%n" || token == "%N" || token == "%m" || token == "%v") {
926                 continue;
927             } else {
928                 toReturn ~= token;
929             }
930         }
931         
932         return toReturn;
933     }
934     
935     /**
936      * Starts the program associated with this .desktop file using urls as command line params.
937      * Note: 
938      *  If the program should be run in terminal it tries to find system defined terminal emulator to run in.
939      *  First, it probes $(B TERM) environment variable. If not found, checks if /usr/bin/x-terminal-emulator exists on Linux and use it on success.
940      *  Defaulted to xterm, if could not determine other terminal emulator.
941      * Note:
942      *  This function does check if the type of desktop file is Application. It relies only on "Exec" value.
943      * Returns:
944      *  Pid of started process.
945      * Throws:
946      *  ProcessException on failure to start the process.
947      *  Exception if expanded exec string is empty.
948      */
949     Pid startApplication(string[] urls = null) const @trusted
950     {
951         auto args = expandExecString(urls);
952         enforce(args.length, "No command line params to run the program. Is Exec missing?");
953         
954         if (terminal()) {
955             string term = environment.get("TERM");
956             
957             version(linux) {
958                 if (term is null) {
959                     string debianTerm = "/usr/bin/x-terminal-emulator";
960                     if (debianTerm.exists) {
961                         term = debianTerm;
962                     }
963                 }
964             }
965             
966             if (term is null) {
967                 term = "xterm";
968             }
969             
970             args = [term, "-e"] ~ args;
971         }
972         
973         return spawnProcess(args, null, Config.none, workingDirectory());
974     }
975     
976     ///ditto, but uses the only url.
977     Pid startApplication(in string url) const @trusted
978     {
979         return startApplication([url]);
980     }
981     
982     Pid startLink() const @trusted
983     {
984         string url = value("URL");
985         return spawnProcess(["xdg-open", url], null, Config.none);
986     }
987     
988 private:
989     DesktopGroup _desktopEntry;
990     string _fileName;
991     
992     size_t[string] _groupIndices;
993     DesktopGroup[] _groups;
994     
995     string[] firstLines;
996 }
997 
998 unittest 
999 {
1000     //Test locale-related functions
1001     assert(makeLocaleName("ru", "RU") == "ru_RU");
1002     assert(makeLocaleName("ru", "RU", "UTF-8") == "ru_RU.UTF-8");
1003     assert(makeLocaleName("ru", "RU", "UTF-8", "mod") == "ru_RU.UTF-8@mod");
1004     assert(makeLocaleName("ru", null, null, "mod") == "ru@mod");
1005     
1006     assert(parseLocaleName("ru_RU.UTF-8@mod") == tuple("ru", "RU", "UTF-8", "mod"));
1007     assert(parseLocaleName("ru@mod") == tuple("ru", string.init, string.init, "mod"));
1008     assert(parseLocaleName("ru_RU") == tuple("ru", "RU", string.init, string.init));
1009     
1010     assert(localizedKey("Name", "ru_RU") == "Name[ru_RU]");
1011     assert(localizedKey("Name", "ru_RU.UTF-8") == "Name[ru_RU]");
1012     assert(localizedKey("Name", "ru", "RU") == "Name[ru_RU]");
1013     
1014     assert(separateFromLocale("Name[ru_RU]") == tuple("Name", "ru_RU"));
1015     assert(separateFromLocale("Name") == tuple("Name", string.init));
1016     
1017     //Test locale matching lookup
1018     auto group = new DesktopGroup("Desktop Entry");
1019     group["Name"] = "Programmer";
1020     group["Name[ru_RU]"] = "Разработчик";
1021     group["Name[ru@jargon]"] = "Кодер";
1022     group["Name[ru]"] = "Программист";
1023     group["GenericName"] = "Program";
1024     group["GenericName[ru]"] = "Программа";
1025     assert(group["Name"] == "Programmer");
1026     assert(group.localizedValue("Name", "ru@jargon") == "Кодер");
1027     assert(group.localizedValue("Name", "ru_RU@jargon") == "Разработчик");
1028     assert(group.localizedValue("Name", "ru") == "Программист");
1029     assert(group.localizedValue("Name", "nonexistent locale") == "Programmer");
1030     assert(group.localizedValue("GenericName", "ru_RU") == "Программа");
1031     
1032     //Test escaping and unescaping
1033     assert("\\next\nline".escapeValue() == `\\next\nline`);
1034     assert(`\\next\nline`.unescapeValue() == "\\next\nline");
1035     
1036     //Test split/join values
1037     
1038     assert(equal(DesktopFile.splitValues("Application;Utility;FileManager;"), ["Application", "Utility", "FileManager"]));
1039     assert(DesktopFile.splitValues(";").empty);
1040     assert(equal(DesktopFile.joinValues(["Application", "Utility", "FileManager"]), "Application;Utility;FileManager;"));
1041     assert(DesktopFile.joinValues([""]).empty);
1042     
1043     //Test DesktopFile
1044     string desktopFileContents = 
1045 `[Desktop Entry]
1046 # Comment
1047 Name=Double Commander
1048 GenericName=File manager
1049 GenericName[ru]=Файловый менеджер
1050 Comment=Double Commander is a cross platform open source file manager with two panels side by side.
1051 Terminal=false
1052 Icon=doublecmd
1053 Exec=doublecmd
1054 Type=Application
1055 Categories=Application;Utility;FileManager;
1056 Keywords=folder;manager;explore;disk;filesystem;orthodox;copy;queue;queuing;operations;`;
1057     
1058     auto df = DesktopFile.loadFromString(desktopFileContents, DesktopFile.ReadOptions.preserveComments);
1059     assert(df.name() == "Double Commander");
1060     assert(df.genericName() == "File manager");
1061     assert(df.localizedValue("GenericName", "ru_RU") == "Файловый менеджер");
1062     assert(!df.terminal());
1063     assert(df.type() == DesktopFile.Type.Application);
1064     assert(equal(df.categories(), ["Application", "Utility", "FileManager"]));
1065     
1066     assert(df.saveToString() == desktopFileContents);
1067     
1068     df = new DesktopFile();
1069     assert(df.desktopEntry());
1070     assert(df.value("Version") == "1.0");
1071     assert(df.categories().empty);
1072     assert(df.type() == DesktopFile.Type.Unknown);
1073     
1074     df.terminal = true;
1075     df.type = DesktopFile.Type.Application;
1076     df.categories = ["Development", "Compilers"];
1077     
1078     assert(df.terminal() == true);
1079     assert(df.type() == DesktopFile.Type.Application);
1080     assert(equal(df.categories(), ["Development", "Compilers"]));
1081 }