2 // Getdown - application installer, patcher and launcher
3 // Copyright (C) 2004-2018 Getdown authors
4 // https://github.com/threerings/getdown/blob/master/LICENSE
6 package com.threerings.getdown.util;
8 import static java.nio.charset.StandardCharsets.UTF_8;
10 import java.io.BufferedReader;
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;
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;
27 import static com.threerings.getdown.Log.log;
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.
37 /** Empty configuration. */
38 public static final Config EMPTY = new Config(new HashMap<String, Object>());
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;
46 // these are filled in by parseConfig
47 public String osname = null;
48 public String osarch = null;
52 * Creates a parse configuration, filling in the platform filters (or not) depending on the
53 * value of {@code checkPlatform}.
55 public static ParseOpts createOpts (boolean checkPlatform) {
56 ParseOpts opts = new ParseOpts();
58 opts.osname = StringUtil.deNull(System.getProperty("os.name")).toLowerCase(Locale.ROOT);
59 opts.osarch = StringUtil.deNull(System.getProperty("os.arch")).toLowerCase(Locale.ROOT);
65 * Parses a configuration file containing key/value pairs. The file must be in the UTF-8
68 * @param opts options that influence the parsing. See {@link #createOpts}.
70 * @return a list of <code>String[]</code> instances containing the key/value pairs in the
71 * order they were parsed from the file.
73 public static List<String[]> parsePairs (File source, ParseOpts opts)
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);
84 * See {@link #parsePairs(File,ParseOpts)}.
86 public static List<String[]> parsePairs (Reader source, ParseOpts opts) throws IOException
88 List<String[]> pairs = new ArrayList<>();
89 for (String line : FileUtil.readLines(source)) {
91 int cidx = line.indexOf("#");
92 if (opts.strictComments ? cidx == 0 : cidx != -1) {
93 line = line.substring(0, cidx);
96 // trim whitespace and skip blank lines
98 if (StringUtil.isBlank(line)) {
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("=");
107 pair[0] = line.substring(0, eidx).trim();
108 pair[1] = line.substring(eidx+1).trim();
114 // if the pair has an os qualifier, we need to process it
115 if (pair[1].startsWith("[")) {
116 int qidx = pair[1].indexOf("]");
118 log.warning("Bogus platform specifier", "key", pair[0], "value", pair[1]);
119 continue; // omit the pair entirely
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]);
129 // otherwise filter out the qualifier text
130 pair[1] = pair[1].substring(qidx+1).trim();
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.
143 public static Rectangle parseRect (String name, String value)
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]);
150 log.warning("Ignoring invalid rect '" + name + "' config '" + value + "'.");
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.
159 public static Integer parseColor (String hexValue)
161 if (!StringUtil.isBlank(hexValue)) {
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);
174 * Parses a configuration file containing key/value pairs. The file must be in the UTF-8
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.
180 public static Config parseConfig (File source, ParseOpts opts)
183 Map<String, Object> data = new HashMap<>();
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,
188 for (String[] pair : parsePairs(source, opts)) {
189 Object value = data.get(pair[0]);
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);
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);
211 return new Config(data);
214 public static Config parseConfig (Reader source, ParseOpts opts)
217 Map<String, Object> data = new HashMap<>();
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,
222 for (String[] pair : parsePairs(source, opts)) {
223 Object value = data.get(pair[0]);
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);
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");
244 System.err.println("##### Reset reader");
245 return parseConfig(source, opts);
248 return new Config(data);
251 public Config (Map<String, Object> data) {
256 * Returns whether {@code name} has a value in this config.
258 public boolean hasValue (String name) {
259 return _data.containsKey(name);
263 * Returns the raw-value for {@code name}. This may be a {@code String}, {@code String[]}, or
266 public Object getRaw (String name) {
267 return _data.get(name);
271 * Returns the specified config value as a string, or {@code null}.
273 public String getString (String name) {
274 return (String)_data.get(name);
278 * Returns the specified config value as a string, or {@code def}.
280 public String getString (String name, String def) {
281 String value = (String)_data.get(name);
282 return value == null ? def : value;
286 * Returns the specified config value as a boolean.
288 public boolean getBoolean (String name) {
289 return Boolean.parseBoolean(getString(name));
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.
296 public String[] getMultiValue (String name)
298 Object value = _data.get(name);
300 return new String[] {};
302 if (value instanceof String) {
303 return new String[] { (String)value };
305 return (String[])value;
309 /** Used to parse rectangle specifications from the config file. */
310 public Rectangle getRect (String name, Rectangle def)
312 String value = getString(name);
313 Rectangle rect = parseRect(name, value);
314 return (rect == null) ? def : rect;
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
322 public int getInt (String name, int def) {
323 String value = getString(name);
325 return value == null ? def : Integer.parseInt(value);
326 } catch (Exception e) {
327 log.warning("Ignoring invalid int '" + name + "' config '" + value + "',");
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
337 public long getLong (String name, long def) {
338 String value = getString(name);
340 return value == null ? def : Long.parseLong(value);
341 } catch (Exception e) {
342 log.warning("Ignoring invalid long '" + name + "' config '" + value + "',");
347 /** Used to parse color specifications from the config file. */
348 public int getColor (String name, int def)
350 String value = getString(name);
351 Integer color = parseColor(value);
352 return (color == null) ? def : color;
355 /** Parses a list of strings from the config file. */
356 public String[] getList (String name)
358 String value = getString(name);
359 return (value == null) ? new String[0] : StringUtil.parseStringArray(value);
363 * Parses a URL from the config file, checking first for a localized version.
365 public String getUrl (String name, String def)
367 String value = getString(name + "." + Locale.getDefault().getLanguage());
368 if (StringUtil.isBlank(value)) {
369 value = getString(name);
371 if (StringUtil.isBlank(value)) {
374 if (!StringUtil.isBlank(value)) {
376 HostWhitelist.verify(new URL(value));
377 } catch (MalformedURLException e) {
378 log.warning("Invalid URL.", "url", value, e);
386 * A helper function for {@link #parsePairs(Reader,ParseOpts)}. Qualifiers have the following
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).
396 protected static boolean checkQualifiers (String quals, String osname, String osarch)
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);
404 return !checkQualifier(quals.substring(1), osname, osarch);
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
411 return false; // we had no positive matches, so return false
414 /** A helper function for {@link #checkQualifiers}. */
415 protected static boolean checkQualifier (String qual, String osname, String osarch)
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);
422 public void mergeConfig(Config newValues, boolean merge) {
424 for (Map.Entry<String, Object> entry : newValues.getData().entrySet()) {
426 String key = entry.getKey();
427 Object nvalue = entry.getValue();
429 String mkey = key.indexOf('.') > -1 ? key.substring(key.indexOf('.') + 1) : key;
430 if (merge && allowedMergeKeys.contains(mkey)) {
432 // merge multi values
434 Object value = _data.get(key);
437 _data.put(key, nvalue);
438 } else if (value instanceof String) {
439 if (nvalue instanceof String) {
441 // value is String, nvalue is String
442 _data.put(key, new String[] { (String)value, (String)nvalue });
444 } else if (nvalue instanceof String[]) {
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);
454 } else if (value instanceof String[]) {
455 if (nvalue instanceof String) {
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);
464 } else if (nvalue instanceof String[]) {
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);
477 } else if (allowedReplaceKeys.contains(mkey)){
480 _data.put(key, nvalue);
483 log.warning("Not merging key '"+key+"' into config");
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();
497 if (val instanceof String) {
498 sb.append((String)val);
499 } else if (val instanceof String[]) {
500 sb.append(Arrays.toString((String[])val));
502 sb.append("Value not String or String[]");
506 return sb.toString();
509 public Map<String, Object> getData() {
513 private final Map<String, Object> _data;
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)