JAL-3130 adapted getdown src. attempt 2. first attempt failed due to cp'ed .git files
[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 java.io.File;
9 import java.io.FileInputStream;
10 import java.io.IOException;
11 import java.io.InputStreamReader;
12 import java.io.Reader;
13 import java.net.MalformedURLException;
14 import java.net.URL;
15 import java.nio.charset.StandardCharsets;
16 import java.util.ArrayList;
17 import java.util.HashMap;
18 import java.util.List;
19 import java.util.Locale;
20 import java.util.Map;
21
22 import static com.threerings.getdown.Log.log;
23
24 /**
25  * Handles parsing and runtime access for Getdown's config files (mainly {@code getdown.txt}).
26  * These files contain zero or more mappings for a particular string key. Config values can be
27  * fetched as single strings, lists of strings, or parsed into primitives or compound data types
28  * like colors and rectangles.
29  */
30 public class Config
31 {
32     /** Empty configuration. */
33     public static final Config EMPTY = new Config(new HashMap<String, Object>());
34
35     /** Options that control the {@link #parsePairs} function. */
36     public static class ParseOpts {
37         // these should be tweaked as desired by the caller
38         public boolean biasToKey = false;
39         public boolean strictComments = false;
40
41         // these are filled in by parseConfig
42         public String osname = null;
43         public String osarch = null;
44     }
45
46     /**
47      * Creates a parse configuration, filling in the platform filters (or not) depending on the
48      * value of {@code checkPlatform}.
49      */
50     public static ParseOpts createOpts (boolean checkPlatform) {
51         ParseOpts opts = new ParseOpts();
52         if (checkPlatform) {
53             opts.osname = StringUtil.deNull(System.getProperty("os.name")).toLowerCase(Locale.ROOT);
54             opts.osarch = StringUtil.deNull(System.getProperty("os.arch")).toLowerCase(Locale.ROOT);
55         }
56         return opts;
57     }
58
59     /**
60      * Parses a configuration file containing key/value pairs. The file must be in the UTF-8
61      * encoding.
62      *
63      * @param opts options that influence the parsing. See {@link #createOpts}.
64      *
65      * @return a list of <code>String[]</code> instances containing the key/value pairs in the
66      * order they were parsed from the file.
67      */
68     public static List<String[]> parsePairs (File source, ParseOpts opts)
69         throws IOException
70     {
71         // annoyingly FileReader does not allow encoding to be specified (uses platform default)
72         try (FileInputStream fis = new FileInputStream(source);
73              InputStreamReader input = new InputStreamReader(fis, StandardCharsets.UTF_8)) {
74             return parsePairs(input, opts);
75         }
76     }
77
78     /**
79      * See {@link #parsePairs(File,ParseOpts)}.
80      */
81     public static List<String[]> parsePairs (Reader source, ParseOpts opts) throws IOException
82     {
83         List<String[]> pairs = new ArrayList<>();
84         for (String line : FileUtil.readLines(source)) {
85             // nix comments
86             int cidx = line.indexOf("#");
87             if (opts.strictComments ? cidx == 0 : cidx != -1) {
88                 line = line.substring(0, cidx);
89             }
90
91             // trim whitespace and skip blank lines
92             line = line.trim();
93             if (StringUtil.isBlank(line)) {
94                 continue;
95             }
96
97             // parse our key/value pair
98             String[] pair = new String[2];
99             // if we're biasing toward key, put all the extra = in the key rather than the value
100             int eidx = opts.biasToKey ? line.lastIndexOf("=") : line.indexOf("=");
101             if (eidx != -1) {
102                 pair[0] = line.substring(0, eidx).trim();
103                 pair[1] = line.substring(eidx+1).trim();
104             } else {
105                 pair[0] = line;
106                 pair[1] = "";
107             }
108
109             // if the pair has an os qualifier, we need to process it
110             if (pair[1].startsWith("[")) {
111                 int qidx = pair[1].indexOf("]");
112                 if (qidx == -1) {
113                     log.warning("Bogus platform specifier", "key", pair[0], "value", pair[1]);
114                     continue; // omit the pair entirely
115                 }
116                 // if we're checking qualifiers and the os doesn't match this qualifier, skip it
117                 String quals = pair[1].substring(1, qidx);
118                 if (opts.osname != null && !checkQualifiers(quals, opts.osname, opts.osarch)) {
119                     log.debug("Skipping", "quals", quals,
120                               "osname", opts.osname, "osarch", opts.osarch,
121                               "key", pair[0], "value", pair[1]);
122                     continue;
123                 }
124                 // otherwise filter out the qualifier text
125                 pair[1] = pair[1].substring(qidx+1).trim();
126             }
127
128             pairs.add(pair);
129         }
130
131         return pairs;
132     }
133
134     /**
135      * Takes a comma-separated String of four integers and returns a rectangle using those ints as
136      * the its x, y, width, and height.
137      */
138     public static Rectangle parseRect (String name, String value)
139     {
140         if (!StringUtil.isBlank(value)) {
141             int[] v = StringUtil.parseIntArray(value);
142             if (v != null && v.length == 4) {
143                 return new Rectangle(v[0], v[1], v[2], v[3]);
144             }
145             log.warning("Ignoring invalid rect '" + name + "' config '" + value + "'.");
146         }
147         return null;
148     }
149
150     /**
151      * Parses the given hex color value (e.g. FFCC99) and returns an {@code Integer} with that
152      * value. If the given value is null or not a valid hexadecimal number, this will return null.
153      */
154     public static Integer parseColor (String hexValue)
155     {
156         if (!StringUtil.isBlank(hexValue)) {
157             try {
158                 // if no alpha channel is specified, use 255 (full alpha)
159                 int alpha = hexValue.length() > 6 ? 0 : 0xFF000000;
160                 return Integer.parseInt(hexValue, 16) | alpha;
161             } catch (NumberFormatException e) {
162                 log.warning("Ignoring invalid color", "hexValue", hexValue, "exception", e);
163             }
164         }
165         return null;
166     }
167
168     /**
169      * Parses a configuration file containing key/value pairs. The file must be in the UTF-8
170      * encoding.
171      *
172      * @return a map from keys to values, where a value will be an array of strings if more than
173      * one key/value pair in the config file was associated with the same key.
174      */
175     public static Config parseConfig (File source, ParseOpts opts)
176         throws IOException
177     {
178         Map<String, Object> data = new HashMap<>();
179
180         // I thought that we could use HashMap<String, String[]> and put new String[] {pair[1]} for
181         // the null case, but it mysteriously dies on launch, so leaving it as HashMap<String,
182         // Object> for now
183         for (String[] pair : parsePairs(source, opts)) {
184             Object value = data.get(pair[0]);
185             if (value == null) {
186                 data.put(pair[0], pair[1]);
187             } else if (value instanceof String) {
188                 data.put(pair[0], new String[] { (String)value, pair[1] });
189             } else if (value instanceof String[]) {
190                 String[] values = (String[])value;
191                 String[] nvalues = new String[values.length+1];
192                 System.arraycopy(values, 0, nvalues, 0, values.length);
193                 nvalues[values.length] = pair[1];
194                 data.put(pair[0], nvalues);
195             }
196         }
197
198         // special magic for the getdown.txt config: if the parsed data contains 'strict_comments =
199         // true' then we reparse the file with strict comments (i.e. # is only assumed to start a
200         // comment in column 0)
201         if (!opts.strictComments && Boolean.parseBoolean((String)data.get("strict_comments"))) {
202             opts.strictComments = true;
203             return parseConfig(source, opts);
204         }
205
206         return new Config(data);
207     }
208
209     public Config (Map<String,  Object> data) {
210         _data = data;
211     }
212
213     /**
214      * Returns whether {@code name} has a value in this config.
215      */
216     public boolean hasValue (String name) {
217         return _data.containsKey(name);
218     }
219
220     /**
221      * Returns the raw-value for {@code name}. This may be a {@code String}, {@code String[]}, or
222      * {@code null}.
223      */
224     public Object getRaw (String name) {
225         return _data.get(name);
226     }
227
228     /**
229      * Returns the specified config value as a string, or {@code null}.
230      */
231     public String getString (String name) {
232         return (String)_data.get(name);
233     }
234
235     /**
236      * Returns the specified config value as a string, or {@code def}.
237      */
238     public String getString (String name, String def) {
239         String value = (String)_data.get(name);
240         return value == null ? def : value;
241     }
242
243     /**
244      * Returns the specified config value as a boolean.
245      */
246     public boolean getBoolean (String name) {
247         return Boolean.parseBoolean(getString(name));
248     }
249
250     /**
251      * Massages a single string into an array and leaves existing array values as is. Simplifies
252      * access to parameters that are expected to be arrays.
253      */
254     public String[] getMultiValue (String name)
255     {
256         Object value = _data.get(name);
257         if (value instanceof String) {
258             return new String[] { (String)value };
259         } else {
260             return (String[])value;
261         }
262     }
263
264     /** Used to parse rectangle specifications from the config file. */
265     public Rectangle getRect (String name, Rectangle def)
266     {
267         String value = getString(name);
268         Rectangle rect = parseRect(name, value);
269         return (rect == null) ? def : rect;
270     }
271
272     /**
273      * Parses and returns the config value for {@code name} as an int. If no value is provided,
274      * {@code def} is returned. If the value is invalid, a warning is logged and {@code def} is
275      * returned.
276      */
277     public int getInt (String name, int def) {
278         String value = getString(name);
279         try {
280             return value == null ? def : Integer.parseInt(value);
281         } catch (Exception e) {
282             log.warning("Ignoring invalid int '" + name + "' config '" + value + "',");
283             return def;
284         }
285     }
286
287     /**
288      * Parses and returns the config value for {@code name} as a long. If no value is provided,
289      * {@code def} is returned. If the value is invalid, a warning is logged and {@code def} is
290      * returned.
291      */
292     public long getLong (String name, long def) {
293         String value = getString(name);
294         try {
295             return value == null ? def : Long.parseLong(value);
296         } catch (Exception e) {
297             log.warning("Ignoring invalid long '" + name + "' config '" + value + "',");
298             return def;
299         }
300     }
301
302     /** Used to parse color specifications from the config file. */
303     public int getColor (String name, int def)
304     {
305         String value = getString(name);
306         Integer color = parseColor(value);
307         return (color == null) ? def : color;
308     }
309
310     /** Parses a list of strings from the config file. */
311     public String[] getList (String name)
312     {
313         String value = getString(name);
314         return (value == null) ? new String[0] : StringUtil.parseStringArray(value);
315     }
316
317     /**
318      * Parses a URL from the config file, checking first for a localized version.
319      */
320     public String getUrl (String name, String def)
321     {
322         String value = getString(name + "." + Locale.getDefault().getLanguage());
323         if (StringUtil.isBlank(value)) {
324             value = getString(name);
325         }
326         if (StringUtil.isBlank(value)) {
327             value = def;
328         }
329         if (!StringUtil.isBlank(value)) {
330             try {
331                 HostWhitelist.verify(new URL(value));
332             } catch (MalformedURLException e) {
333                 log.warning("Invalid URL.", "url", value, e);
334                 value = null;
335             }
336         }
337         return value;
338     }
339
340     /**
341      * A helper function for {@link #parsePairs(Reader,ParseOpts)}. Qualifiers have the following
342      * form:
343      * <pre>
344      * id = os[-arch]
345      * ids = id | id,ids
346      * quals = !id | ids
347      * </pre>
348      * Examples: [linux-amd64,linux-x86_64], [windows], [mac os x], [!windows]. Negative qualifiers
349      * must appear alone, they cannot be used with other qualifiers (positive or negative).
350      */
351     protected static boolean checkQualifiers (String quals, String osname, String osarch)
352     {
353         if (quals.startsWith("!")) {
354             if (quals.indexOf(",") != -1) { // sanity check
355                 log.warning("Multiple qualifiers cannot be used when one of the qualifiers " +
356                             "is negative", "quals", quals);
357                 return false;
358             }
359             return !checkQualifier(quals.substring(1), osname, osarch);
360         }
361         for (String qual : quals.split(",")) {
362             if (checkQualifier(qual, osname, osarch)) {
363                 return true; // if we have a positive match, we can immediately return true
364             }
365         }
366         return false; // we had no positive matches, so return false
367     }
368
369     /** A helper function for {@link #checkQualifiers}. */
370     protected static boolean checkQualifier (String qual, String osname, String osarch)
371     {
372         String[] bits = qual.trim().toLowerCase(Locale.ROOT).split("-");
373         String os = bits[0], arch = (bits.length > 1) ? bits[1] : "";
374         return (osname.indexOf(os) != -1) && (osarch.indexOf(arch) != -1);
375     }
376
377     private final Map<String, Object> _data;
378 }