// // Getdown - application installer, patcher and launcher // Copyright (C) 2004-2018 Getdown authors // https://github.com/threerings/getdown/blob/master/LICENSE package com.threerings.getdown.util; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStreamReader; import java.io.Reader; import java.net.MalformedURLException; import java.net.URL; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; import static com.threerings.getdown.Log.log; /** * Handles parsing and runtime access for Getdown's config files (mainly {@code getdown.txt}). * These files contain zero or more mappings for a particular string key. Config values can be * fetched as single strings, lists of strings, or parsed into primitives or compound data types * like colors and rectangles. */ public class Config { /** Empty configuration. */ public static final Config EMPTY = new Config(new HashMap()); /** Options that control the {@link #parsePairs} function. */ public static class ParseOpts { // these should be tweaked as desired by the caller public boolean biasToKey = false; public boolean strictComments = false; // these are filled in by parseConfig public String osname = null; public String osarch = null; } /** * Creates a parse configuration, filling in the platform filters (or not) depending on the * value of {@code checkPlatform}. */ public static ParseOpts createOpts (boolean checkPlatform) { ParseOpts opts = new ParseOpts(); if (checkPlatform) { opts.osname = StringUtil.deNull(System.getProperty("os.name")).toLowerCase(Locale.ROOT); opts.osarch = StringUtil.deNull(System.getProperty("os.arch")).toLowerCase(Locale.ROOT); } return opts; } /** * Parses a configuration file containing key/value pairs. The file must be in the UTF-8 * encoding. * * @param opts options that influence the parsing. See {@link #createOpts}. * * @return a list of String[] instances containing the key/value pairs in the * order they were parsed from the file. */ public static List parsePairs (File source, ParseOpts opts) throws IOException { // annoyingly FileReader does not allow encoding to be specified (uses platform default) try (FileInputStream fis = new FileInputStream(source); InputStreamReader input = new InputStreamReader(fis, StandardCharsets.UTF_8)) { return parsePairs(input, opts); } } /** * See {@link #parsePairs(File,ParseOpts)}. */ public static List parsePairs (Reader source, ParseOpts opts) throws IOException { List pairs = new ArrayList<>(); for (String line : FileUtil.readLines(source)) { // nix comments int cidx = line.indexOf("#"); if (opts.strictComments ? cidx == 0 : cidx != -1) { line = line.substring(0, cidx); } // trim whitespace and skip blank lines line = line.trim(); if (StringUtil.isBlank(line)) { continue; } // parse our key/value pair String[] pair = new String[2]; // if we're biasing toward key, put all the extra = in the key rather than the value int eidx = opts.biasToKey ? line.lastIndexOf("=") : line.indexOf("="); if (eidx != -1) { pair[0] = line.substring(0, eidx).trim(); pair[1] = line.substring(eidx+1).trim(); } else { pair[0] = line; pair[1] = ""; } // if the pair has an os qualifier, we need to process it if (pair[1].startsWith("[")) { int qidx = pair[1].indexOf("]"); if (qidx == -1) { log.warning("Bogus platform specifier", "key", pair[0], "value", pair[1]); continue; // omit the pair entirely } // if we're checking qualifiers and the os doesn't match this qualifier, skip it String quals = pair[1].substring(1, qidx); if (opts.osname != null && !checkQualifiers(quals, opts.osname, opts.osarch)) { log.debug("Skipping", "quals", quals, "osname", opts.osname, "osarch", opts.osarch, "key", pair[0], "value", pair[1]); continue; } // otherwise filter out the qualifier text pair[1] = pair[1].substring(qidx+1).trim(); } pairs.add(pair); } return pairs; } /** * Takes a comma-separated String of four integers and returns a rectangle using those ints as * the its x, y, width, and height. */ public static Rectangle parseRect (String name, String value) { if (!StringUtil.isBlank(value)) { int[] v = StringUtil.parseIntArray(value); if (v != null && v.length == 4) { return new Rectangle(v[0], v[1], v[2], v[3]); } log.warning("Ignoring invalid rect '" + name + "' config '" + value + "'."); } return null; } /** * Parses the given hex color value (e.g. FFCC99) and returns an {@code Integer} with that * value. If the given value is null or not a valid hexadecimal number, this will return null. */ public static Integer parseColor (String hexValue) { if (!StringUtil.isBlank(hexValue)) { try { // if no alpha channel is specified, use 255 (full alpha) int alpha = hexValue.length() > 6 ? 0 : 0xFF000000; return Integer.parseInt(hexValue, 16) | alpha; } catch (NumberFormatException e) { log.warning("Ignoring invalid color", "hexValue", hexValue, "exception", e); } } return null; } /** * Parses a configuration file containing key/value pairs. The file must be in the UTF-8 * encoding. * * @return a map from keys to values, where a value will be an array of strings if more than * one key/value pair in the config file was associated with the same key. */ public static Config parseConfig (File source, ParseOpts opts) throws IOException { Map data = new HashMap<>(); // I thought that we could use HashMap and put new String[] {pair[1]} for // the null case, but it mysteriously dies on launch, so leaving it as HashMap for now for (String[] pair : parsePairs(source, opts)) { Object value = data.get(pair[0]); if (value == null) { data.put(pair[0], pair[1]); } else if (value instanceof String) { data.put(pair[0], new String[] { (String)value, pair[1] }); } else if (value instanceof String[]) { String[] values = (String[])value; String[] nvalues = new String[values.length+1]; System.arraycopy(values, 0, nvalues, 0, values.length); nvalues[values.length] = pair[1]; data.put(pair[0], nvalues); } } // special magic for the getdown.txt config: if the parsed data contains 'strict_comments = // true' then we reparse the file with strict comments (i.e. # is only assumed to start a // comment in column 0) if (!opts.strictComments && Boolean.parseBoolean((String)data.get("strict_comments"))) { opts.strictComments = true; return parseConfig(source, opts); } return new Config(data); } public Config (Map data) { _data = data; } /** * Returns whether {@code name} has a value in this config. */ public boolean hasValue (String name) { return _data.containsKey(name); } /** * Returns the raw-value for {@code name}. This may be a {@code String}, {@code String[]}, or * {@code null}. */ public Object getRaw (String name) { return _data.get(name); } /** * Returns the specified config value as a string, or {@code null}. */ public String getString (String name) { return (String)_data.get(name); } /** * Returns the specified config value as a string, or {@code def}. */ public String getString (String name, String def) { String value = (String)_data.get(name); return value == null ? def : value; } /** * Returns the specified config value as a boolean. */ public boolean getBoolean (String name) { return Boolean.parseBoolean(getString(name)); } /** * Massages a single string into an array and leaves existing array values as is. Simplifies * access to parameters that are expected to be arrays. */ public String[] getMultiValue (String name) { Object value = _data.get(name); if (value instanceof String) { return new String[] { (String)value }; } else { return (String[])value; } } /** Used to parse rectangle specifications from the config file. */ public Rectangle getRect (String name, Rectangle def) { String value = getString(name); Rectangle rect = parseRect(name, value); return (rect == null) ? def : rect; } /** * Parses and returns the config value for {@code name} as an int. If no value is provided, * {@code def} is returned. If the value is invalid, a warning is logged and {@code def} is * returned. */ public int getInt (String name, int def) { String value = getString(name); try { return value == null ? def : Integer.parseInt(value); } catch (Exception e) { log.warning("Ignoring invalid int '" + name + "' config '" + value + "',"); return def; } } /** * Parses and returns the config value for {@code name} as a long. If no value is provided, * {@code def} is returned. If the value is invalid, a warning is logged and {@code def} is * returned. */ public long getLong (String name, long def) { String value = getString(name); try { return value == null ? def : Long.parseLong(value); } catch (Exception e) { log.warning("Ignoring invalid long '" + name + "' config '" + value + "',"); return def; } } /** Used to parse color specifications from the config file. */ public int getColor (String name, int def) { String value = getString(name); Integer color = parseColor(value); return (color == null) ? def : color; } /** Parses a list of strings from the config file. */ public String[] getList (String name) { String value = getString(name); return (value == null) ? new String[0] : StringUtil.parseStringArray(value); } /** * Parses a URL from the config file, checking first for a localized version. */ public String getUrl (String name, String def) { String value = getString(name + "." + Locale.getDefault().getLanguage()); if (StringUtil.isBlank(value)) { value = getString(name); } if (StringUtil.isBlank(value)) { value = def; } if (!StringUtil.isBlank(value)) { try { HostWhitelist.verify(new URL(value)); } catch (MalformedURLException e) { log.warning("Invalid URL.", "url", value, e); value = null; } } return value; } /** * A helper function for {@link #parsePairs(Reader,ParseOpts)}. Qualifiers have the following * form: *
     * id = os[-arch]
     * ids = id | id,ids
     * quals = !id | ids
     * 
* Examples: [linux-amd64,linux-x86_64], [windows], [mac os x], [!windows]. Negative qualifiers * must appear alone, they cannot be used with other qualifiers (positive or negative). */ protected static boolean checkQualifiers (String quals, String osname, String osarch) { if (quals.startsWith("!")) { if (quals.indexOf(",") != -1) { // sanity check log.warning("Multiple qualifiers cannot be used when one of the qualifiers " + "is negative", "quals", quals); return false; } return !checkQualifier(quals.substring(1), osname, osarch); } for (String qual : quals.split(",")) { if (checkQualifier(qual, osname, osarch)) { return true; // if we have a positive match, we can immediately return true } } return false; // we had no positive matches, so return false } /** A helper function for {@link #checkQualifiers}. */ protected static boolean checkQualifier (String qual, String osname, String osarch) { String[] bits = qual.trim().toLowerCase(Locale.ROOT).split("-"); String os = bits[0], arch = (bits.length > 1) ? bits[1] : ""; return (osname.indexOf(os) != -1) && (osarch.indexOf(arch) != -1); } private final Map _data; }