JAL-4409 Added multiple file open to getdown. Added URL source of JVL file ability...
[jalview.git] / getdown / src / getdown / core / src / main / java / com / threerings / getdown / util / Config.java
1 //
2 // Getdown - application installer, patcher and launcher
3 // Copyright (C) 2004-2018 Getdown authors
4 // https://github.com/threerings/getdown/blob/master/LICENSE
5
6 package com.threerings.getdown.util;
7
8 import static java.nio.charset.StandardCharsets.UTF_8;
9
10 import java.io.BufferedReader;
11 import java.io.File;
12 import java.io.FileInputStream;
13 import java.io.IOException;
14 import java.io.InputStream;
15 import java.io.InputStreamReader;
16 import java.io.Reader;
17 import java.net.MalformedURLException;
18 import java.net.URL;
19 import java.nio.charset.StandardCharsets;
20 import java.util.ArrayList;
21 import java.util.Arrays;
22 import java.util.HashMap;
23 import java.util.List;
24 import java.util.Locale;
25 import java.util.Map;
26
27 import static com.threerings.getdown.Log.log;
28
29 /**
30  * Handles parsing and runtime access for Getdown's config files (mainly {@code getdown.txt}).
31  * These files contain zero or more mappings for a particular string key. Config values can be
32  * fetched as single strings, lists of strings, or parsed into primitives or compound data types
33  * like colors and rectangles.
34  */
35 public class Config
36 {
37     /** Empty configuration. */
38     public static final Config EMPTY = new Config(new HashMap<String, Object>());
39
40     /** Options that control the {@link #parsePairs} function. */
41     public static class ParseOpts {
42         // these should be tweaked as desired by the caller
43         public boolean biasToKey = false;
44         public boolean strictComments = false;
45
46         // these are filled in by parseConfig
47         public String osname = null;
48         public String osarch = null;
49     }
50
51     /**
52      * Creates a parse configuration, filling in the platform filters (or not) depending on the
53      * value of {@code checkPlatform}.
54      */
55     public static ParseOpts createOpts (boolean checkPlatform) {
56         ParseOpts opts = new ParseOpts();
57         if (checkPlatform) {
58             opts.osname = StringUtil.deNull(System.getProperty("os.name")).toLowerCase(Locale.ROOT);
59             opts.osarch = StringUtil.deNull(System.getProperty("os.arch")).toLowerCase(Locale.ROOT);
60         }
61         return opts;
62     }
63
64     /**
65      * Parses a configuration file containing key/value pairs. The file must be in the UTF-8
66      * encoding.
67      *
68      * @param opts options that influence the parsing. See {@link #createOpts}.
69      *
70      * @return a list of <code>String[]</code> instances containing the key/value pairs in the
71      * order they were parsed from the file.
72      */
73     public static List<String[]> parsePairs (File source, ParseOpts opts)
74         throws IOException
75     {
76         // annoyingly FileReader does not allow encoding to be specified (uses platform default)
77         try (FileInputStream fis = new FileInputStream(source);
78              InputStreamReader input = new InputStreamReader(fis, StandardCharsets.UTF_8)) {
79             return parsePairs(input, opts);
80         }
81     }
82
83     /**
84      * See {@link #parsePairs(File,ParseOpts)}.
85      */
86     public static List<String[]> parsePairs (Reader source, ParseOpts opts) throws IOException
87     {
88         List<String[]> pairs = new ArrayList<>();
89         for (String line : FileUtil.readLines(source)) {
90             // nix comments
91             int cidx = line.indexOf("#");
92             if (opts.strictComments ? cidx == 0 : cidx != -1) {
93                 line = line.substring(0, cidx);
94             }
95
96             // trim whitespace and skip blank lines
97             line = line.trim();
98             if (StringUtil.isBlank(line)) {
99                 continue;
100             }
101
102             // parse our key/value pair
103             String[] pair = new String[2];
104             // if we're biasing toward key, put all the extra = in the key rather than the value
105             int eidx = opts.biasToKey ? line.lastIndexOf("=") : line.indexOf("=");
106             if (eidx != -1) {
107                 pair[0] = line.substring(0, eidx).trim();
108                 pair[1] = line.substring(eidx+1).trim();
109             } else {
110                 pair[0] = line;
111                 pair[1] = "";
112             }
113
114             // if the pair has an os qualifier, we need to process it
115             if (pair[1].startsWith("[")) {
116                 int qidx = pair[1].indexOf("]");
117                 if (qidx == -1) {
118                     log.warning("Bogus platform specifier", "key", pair[0], "value", pair[1]);
119                     continue; // omit the pair entirely
120                 }
121                 // if we're checking qualifiers and the os doesn't match this qualifier, skip it
122                 String quals = pair[1].substring(1, qidx);
123                 if (opts.osname != null && !checkQualifiers(quals, opts.osname, opts.osarch)) {
124                     log.debug("Skipping", "quals", quals,
125                               "osname", opts.osname, "osarch", opts.osarch,
126                               "key", pair[0], "value", pair[1]);
127                     continue;
128                 }
129                 // otherwise filter out the qualifier text
130                 pair[1] = pair[1].substring(qidx+1).trim();
131             }
132
133             pairs.add(pair);
134         }
135
136         return pairs;
137     }
138
139     /**
140      * Takes a comma-separated String of four integers and returns a rectangle using those ints as
141      * the its x, y, width, and height.
142      */
143     public static Rectangle parseRect (String name, String value)
144     {
145         if (!StringUtil.isBlank(value)) {
146             int[] v = StringUtil.parseIntArray(value);
147             if (v != null && v.length == 4) {
148                 return new Rectangle(v[0], v[1], v[2], v[3]);
149             }
150             log.warning("Ignoring invalid rect '" + name + "' config '" + value + "'.");
151         }
152         return null;
153     }
154
155     /**
156      * Parses the given hex color value (e.g. FFCC99) and returns an {@code Integer} with that
157      * value. If the given value is null or not a valid hexadecimal number, this will return null.
158      */
159     public static Integer parseColor (String hexValue)
160     {
161         if (!StringUtil.isBlank(hexValue)) {
162             try {
163                 // if no alpha channel is specified, use 255 (full alpha)
164                 int alpha = hexValue.length() > 6 ? 0 : 0xFF000000;
165                 return Integer.parseInt(hexValue, 16) | alpha;
166             } catch (NumberFormatException e) {
167                 log.warning("Ignoring invalid color", "hexValue", hexValue, "exception", e);
168             }
169         }
170         return null;
171     }
172
173     /**
174      * Parses a configuration file containing key/value pairs. The file must be in the UTF-8
175      * encoding.
176      *
177      * @return a map from keys to values, where a value will be an array of strings if more than
178      * one key/value pair in the config file was associated with the same key.
179      */
180     public static Config parseConfig (File source, ParseOpts opts)
181             throws IOException
182         {
183             Map<String, Object> data = new HashMap<>();
184
185             // I thought that we could use HashMap<String, String[]> and put new String[] {pair[1]} for
186             // the null case, but it mysteriously dies on launch, so leaving it as HashMap<String,
187             // Object> for now
188             for (String[] pair : parsePairs(source, opts)) {
189                 Object value = data.get(pair[0]);
190                 if (value == null) {
191                     data.put(pair[0], pair[1]);
192                 } else if (value instanceof String) {
193                     data.put(pair[0], new String[] { (String)value, pair[1] });
194                 } else if (value instanceof String[]) {
195                     String[] values = (String[])value;
196                     String[] nvalues = new String[values.length+1];
197                     System.arraycopy(values, 0, nvalues, 0, values.length);
198                     nvalues[values.length] = pair[1];
199                     data.put(pair[0], nvalues);
200                 }
201             }
202
203             // special magic for the getdown.txt config: if the parsed data contains 'strict_comments =
204             // true' then we reparse the file with strict comments (i.e. # is only assumed to start a
205             // comment in column 0)
206             if (!opts.strictComments && Boolean.parseBoolean((String)data.get("strict_comments"))) {
207                 opts.strictComments = true;
208                 return parseConfig(source, opts);
209             }
210
211             return new Config(data);
212         }
213
214     public static Config parseConfig (Reader source, ParseOpts opts)
215             throws IOException
216         {
217             Map<String, Object> data = new HashMap<>();
218
219             // I thought that we could use HashMap<String, String[]> and put new String[] {pair[1]} for
220             // the null case, but it mysteriously dies on launch, so leaving it as HashMap<String,
221             // Object> for now
222             for (String[] pair : parsePairs(source, opts)) {
223                 Object value = data.get(pair[0]);
224                 if (value == null) {
225                     data.put(pair[0], pair[1]);
226                 } else if (value instanceof String) {
227                     data.put(pair[0], new String[] { (String)value, pair[1] });
228                 } else if (value instanceof String[]) {
229                     String[] values = (String[])value;
230                     String[] nvalues = new String[values.length+1];
231                     System.arraycopy(values, 0, nvalues, 0, values.length);
232                     nvalues[values.length] = pair[1];
233                     data.put(pair[0], nvalues);
234                 }
235             }
236
237             // special magic for the getdown.txt config: if the parsed data contains 'strict_comments =
238             // true' then we reparse the file with strict comments (i.e. # is only assumed to start a
239             // comment in column 0)
240             if (!opts.strictComments && Boolean.parseBoolean((String)data.get("strict_comments"))) {
241                 opts.strictComments = true;
242                 System.err.println("##### About to reset reader");
243                 source.reset();
244                 System.err.println("##### Reset reader");
245                 return parseConfig(source, opts);
246             }
247
248             return new Config(data);
249         }
250
251     public Config (Map<String,  Object> data) {
252         _data = data;
253     }
254
255     /**
256      * Returns whether {@code name} has a value in this config.
257      */
258     public boolean hasValue (String name) {
259         return _data.containsKey(name);
260     }
261
262     /**
263      * Returns the raw-value for {@code name}. This may be a {@code String}, {@code String[]}, or
264      * {@code null}.
265      */
266     public Object getRaw (String name) {
267         return _data.get(name);
268     }
269
270     /**
271      * Returns the specified config value as a string, or {@code null}.
272      */
273     public String getString (String name) {
274         return (String)_data.get(name);
275     }
276
277     /**
278      * Returns the specified config value as a string, or {@code def}.
279      */
280     public String getString (String name, String def) {
281         String value = (String)_data.get(name);
282         return value == null ? def : value;
283     }
284
285     /**
286      * Returns the specified config value as a boolean.
287      */
288     public boolean getBoolean (String name) {
289         return Boolean.parseBoolean(getString(name));
290     }
291
292     /**
293      * Massages a single string into an array and leaves existing array values as is. Simplifies
294      * access to parameters that are expected to be arrays.
295      */
296     public String[] getMultiValue (String name)
297     {
298         Object value = _data.get(name);
299         if (value == null) {
300           return new String[] {};
301         }
302         if (value instanceof String) {
303             return new String[] { (String)value };
304         } else {
305             return (String[])value;
306         }
307     }
308
309     /** Used to parse rectangle specifications from the config file. */
310     public Rectangle getRect (String name, Rectangle def)
311     {
312         String value = getString(name);
313         Rectangle rect = parseRect(name, value);
314         return (rect == null) ? def : rect;
315     }
316
317     /**
318      * Parses and returns the config value for {@code name} as an int. If no value is provided,
319      * {@code def} is returned. If the value is invalid, a warning is logged and {@code def} is
320      * returned.
321      */
322     public int getInt (String name, int def) {
323         String value = getString(name);
324         try {
325             return value == null ? def : Integer.parseInt(value);
326         } catch (Exception e) {
327             log.warning("Ignoring invalid int '" + name + "' config '" + value + "',");
328             return def;
329         }
330     }
331
332     /**
333      * Parses and returns the config value for {@code name} as a long. If no value is provided,
334      * {@code def} is returned. If the value is invalid, a warning is logged and {@code def} is
335      * returned.
336      */
337     public long getLong (String name, long def) {
338         String value = getString(name);
339         try {
340             return value == null ? def : Long.parseLong(value);
341         } catch (Exception e) {
342             log.warning("Ignoring invalid long '" + name + "' config '" + value + "',");
343             return def;
344         }
345     }
346
347     /** Used to parse color specifications from the config file. */
348     public int getColor (String name, int def)
349     {
350         String value = getString(name);
351         Integer color = parseColor(value);
352         return (color == null) ? def : color;
353     }
354
355     /** Parses a list of strings from the config file. */
356     public String[] getList (String name)
357     {
358         String value = getString(name);
359         return (value == null) ? new String[0] : StringUtil.parseStringArray(value);
360     }
361
362     /**
363      * Parses a URL from the config file, checking first for a localized version.
364      */
365     public String getUrl (String name, String def)
366     {
367         String value = getString(name + "." + Locale.getDefault().getLanguage());
368         if (StringUtil.isBlank(value)) {
369             value = getString(name);
370         }
371         if (StringUtil.isBlank(value)) {
372             value = def;
373         }
374         if (!StringUtil.isBlank(value)) {
375             try {
376                 HostWhitelist.verify(new URL(value));
377             } catch (MalformedURLException e) {
378                 log.warning("Invalid URL.", "url", value, e);
379                 value = null;
380             }
381         }
382         return value;
383     }
384
385     /**
386      * A helper function for {@link #parsePairs(Reader,ParseOpts)}. Qualifiers have the following
387      * form:
388      * <pre>
389      * id = os[-arch]
390      * ids = id | id,ids
391      * quals = !id | ids
392      * </pre>
393      * Examples: [linux-amd64,linux-x86_64], [windows], [mac os x], [!windows]. Negative qualifiers
394      * must appear alone, they cannot be used with other qualifiers (positive or negative).
395      */
396     protected static boolean checkQualifiers (String quals, String osname, String osarch)
397     {
398         if (quals.startsWith("!")) {
399             if (quals.indexOf(",") != -1) { // sanity check
400                 log.warning("Multiple qualifiers cannot be used when one of the qualifiers " +
401                             "is negative", "quals", quals);
402                 return false;
403             }
404             return !checkQualifier(quals.substring(1), osname, osarch);
405         }
406         for (String qual : quals.split(",")) {
407             if (checkQualifier(qual, osname, osarch)) {
408                 return true; // if we have a positive match, we can immediately return true
409             }
410         }
411         return false; // we had no positive matches, so return false
412     }
413
414     /** A helper function for {@link #checkQualifiers}. */
415     protected static boolean checkQualifier (String qual, String osname, String osarch)
416     {
417         String[] bits = qual.trim().toLowerCase(Locale.ROOT).split("-");
418         String os = bits[0], arch = (bits.length > 1) ? bits[1] : "";
419         return (osname.indexOf(os) != -1) && (osarch.indexOf(arch) != -1);
420     }
421     
422     public void mergeConfig(Config newValues, boolean merge) {
423       
424       for (Map.Entry<String, Object> entry : newValues.getData().entrySet()) {
425         
426         String key = entry.getKey();
427         Object nvalue = entry.getValue();
428
429         String mkey = key.indexOf('.') > -1 ? key.substring(key.indexOf('.') + 1) : key;
430         if (merge && allowedMergeKeys.contains(mkey)) {
431           
432           // merge multi values
433           
434           Object value = _data.get(key);
435           
436           if (value == null) {
437             _data.put(key, nvalue);
438           } else if (value instanceof String) {
439             if (nvalue instanceof String) {
440               
441               // value is String, nvalue is String
442               _data.put(key, new String[] { (String)value, (String)nvalue });
443               
444             } else if (nvalue instanceof String[]) {
445               
446               // value is String, nvalue is String[]
447               String[] nvalues = (String[])nvalue;
448               String[] newvalues = new String[nvalues.length+1];
449               newvalues[0] = (String)value;
450               System.arraycopy(nvalues, 0, newvalues, 1, nvalues.length);
451               _data.put(key, newvalues);
452               
453             }
454           } else if (value instanceof String[]) {
455             if (nvalue instanceof String) {
456               
457               // value is String[], nvalue is String
458               String[] values = (String[])value;
459               String[] newvalues = new String[values.length+1];
460               System.arraycopy(values, 0, newvalues, 0, values.length);
461               newvalues[values.length] = (String)nvalue;
462               _data.put(key, newvalues);
463               
464             } else if (nvalue instanceof String[]) {
465               
466               // value is String[], nvalue is String[]
467               String[] values = (String[])value;
468               String[] nvalues = (String[])nvalue;
469               String[] newvalues = new String[values.length + nvalues.length];
470               System.arraycopy(values, 0, newvalues, 0, values.length);
471               System.arraycopy(nvalues, 0, newvalues, values.length, newvalues.length);
472               _data.put(key, newvalues);
473               
474             }
475           }
476           
477         } else if (allowedReplaceKeys.contains(mkey)){
478           
479           // replace value
480           _data.put(key, nvalue);
481           
482         } else {
483           log.warning("Not merging key '"+key+"' into config");
484         }
485
486       }
487       
488     }
489     
490     public String toString() {
491       StringBuilder sb = new StringBuilder();
492       for (Map.Entry<String, Object> entry : getData().entrySet()) {
493         String key = entry.getKey();
494         Object val = entry.getValue();
495         sb.append(key);
496         sb.append("=");
497         if (val instanceof String) {
498           sb.append((String)val);
499         } else if (val instanceof String[]) {
500           sb.append(Arrays.toString((String[])val));
501         } else {
502           sb.append("Value not String or String[]");
503         }
504         sb.append("\n");
505       }
506       return sb.toString();
507     }
508     
509     public Map<String, Object> getData() {
510       return _data;
511     }
512
513     private final Map<String, Object> _data;
514  
515     public static final List<String> allowedReplaceKeys = Arrays.asList("appbase","apparg","jvmarg","jvmmempc","jvmmemmax"); // these are the ones we might use
516     public static final List<String> allowedMergeKeys = Arrays.asList("apparg","jvmarg"); // these are the ones we might use
517     //private final List<String> allowedMergeKeys = Arrays.asList("apparg","jvmarg","resource","code","java_location"); // (not exhaustive list here)
518 }