RefCapablePropertyResourceBundle.java

/* Copyright (c) 2001-2024, The HSQL Development Group
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 *
 * Redistributions of source code must retain the above copyright notice, this
 * list of conditions and the following disclaimer.
 *
 * Redistributions in binary form must reproduce the above copyright notice,
 * this list of conditions and the following disclaimer in the documentation
 * and/or other materials provided with the distribution.
 *
 * Neither the name of the HSQL Development Group nor the names of its
 * contributors may be used to endorse or promote products derived from this
 * software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
 * ARE DISCLAIMED. IN NO EVENT SHALL HSQL DEVELOPMENT GROUP, HSQLDB.ORG,
 * OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
 * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
 * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
 * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
 * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */


package org.hsqldb.lib;

import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.MissingResourceException;
import java.util.PropertyResourceBundle;
import java.util.ResourceBundle;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/* $Id: RefCapablePropertyResourceBundle.java 6697 2024-03-12 22:56:35Z fredt $ */

/**
 * Just like PropertyResourceBundle, except keys mapped to nothing in the
 * properties file will load the final String value from a text file.
 *
 * The use case is where one wants to use a ResourceBundle for Strings,
 * but some of the Strings are long-- too long to maintain in a Java
 * .properties file.
 * By using this class, you can put each such long String in its own
 * separate file, yet all keys mapped to (non-empty) values in the
 * .properties file will behave just like regular PropertyResourceBundle
 * properties.
 * In this documentation, I call these values read in atomically from
 * other files <i>referenced</i> values, because the values are not directly
 * in the .properties file, but are "referenced" in the .properties file
 * by virtue of the empty value for the key.
 *
 * You use this class in the same way as you would traditionally use
 * ResourceBundle:
 * <PRE>
 *  import org.hsqldb.util..RefCapablePropertyResourceBundle;
 *  ...
 *      RefCapablePropertyResourceBundle bundle =
 *              RefCapablePropertyResourceBundle.getBundle("subdir.xyz");
 *      System.out.println("Value for '1' = (" + bundle.getString("1") + ')');
 * </PRE>
 *
 * Just like PropertyResourceBundle, the .properties file and the
 * <i>referenced</i> files are read in from the classpath by a class loader,
 * according to the normal ResourceBundle rules.
 * To eliminate the need to prohibit the use of any strings in the .properties
 * values, and to enforce consistency, you <b>must</b> use the following rules
 * to when putting your referenced files into place.
 * <P>
 * REFERENCED FILE DIRECTORY is a directory named with the base name of the
 * properties file, and in the same parent directory.  So, the referenced
 * file directory {@code /a/b/c/greentea} is used to hold all reference
 * files for properties files {@code /a/b/c/greentea_en_us.properties},
 * {@code /a/b/c/greentea_de.properties},
 * {@code /a/b/c/greentea.properties}, etc.
 * (BTW, according to ResourceBundle rules, this resource should be looked
 * up with name "a.b.c.greentea", not "/a/b/c..." or "a/b/c").
 * REFERENCED FILES themselves all have the base name of the property key,
 * with locale appendages exactly as the <i>referring</i> properties files
 * has, plus the suffix {@code .text}.
 * <P>
 * So, if we have the following line in
 * {@code /a/b/c/greentea_de.properties}:
 * <PRE>
 *     1: eins
 * </PRE>
 * then you <b>must</b> have a reference text file
 * {@code /a/b/c/greentea/1_de.properties}:
 * <P>
 * In reference text files,
 * sequences of "\r", "\n" and "\r\n" are all translated to the line
 * delimiter for your platform (System property {@code line.separator}).
 * If one of those sequences exists at the very end of the file, it will be
 * eliminated (so, if you really want getString() to end with a line delimiter,
 * end your file with two of them).
 * (The file itself is never modified-- I'm talking about the value returned
 * by {@code getString(String)}).
 * <P>
 * To prevent throwing at runtime due to unset variables, use a wrapper class
 * like SqltoolRB (use SqltoolRB.java as a template).
 * To prevent throwing at runtime due to unset System Properties, or
 * insufficient parameters passed to getString(String, String[]), set the
 * behavior values appropriately.
 * <P>
 * Just like all Properties files, referenced files must use ISO-8859-1
 * encoding, with unicode escapes for characters outside of ISO-8859-1
 * character set.  But, unlike Properties files, \ does not need to be
 * escaped for normal usage.
 * <P>
 * The getString() methods with more than one parameter substitute for
 * "positional" parameters of the form "%{1}".
 * The getExpandedString() methods substitute for System Property names
 * of the form "${1}".
 * In both cases, you can interpose :+ and a string between the variable
 * name and the closing }.  This works just like the Bourne shell
 * ${x:+y} feature.  If "x" is set, then "y" is returned, and "y" may
 * contain references to the original variable without the curly braces.
 * In this file, I refer to the y text as the "conditional string".
 * One example of each type:
 * <PRE>
 *     Out val = (${condlSysProp:+Prop condlSysProp is set to $condlSysProp.})
 *     Out val = (%{2:+Pos Var #2 is set to %2.})
 * OUTPUT if neither are set:
 *     Out val = ()
 *     Out val = ()
 * OUTPUT if condlSysProp=alpha and condlPLvar=beta:
 *     Out val = (Prop condlSysProp is set to alpha.)
 *     Out val = (Pos Var #2 is set to beta.)
 * </PRE>
 * This feature has the following limitations.
 * <UL>
 *   <LI>The conditional string may only contain the primary variable.
 *   <LI>Inner instances of the primary variable may not use curly braces,
 *       and therefore the variable name must end at a word boundary.
 * </UL>
 * The conditional string may span newlines, and it is often very useful
 * to do so.
 *
 * @see java.util.PropertyResourceBundle
 * @see java.util.ResourceBundle
 * @author Blaine Simpson (blaine dot simpson at admc dot com)
 */
public class RefCapablePropertyResourceBundle {
    private PropertyResourceBundle wrappedBundle;
    private String baseName;
    private String language, country, variant;
    static private Map<ResourceBundle, RefCapablePropertyResourceBundle>
            allBundles =
            new HashMap<>();
    public static final String LS = System.getProperty("line.separator");
    private Pattern sysPropVarPattern = Pattern.compile(
            "(?s)\\Q${\\E([^}]+?)(?:\\Q:+\\E([^}]+))?\\Q}");
    private Pattern posPattern = Pattern.compile(
            "(?s)\\Q%{\\E(\\d)(?:\\Q:+\\E([^}]+))?\\Q}");
    private Class<? extends Enum<?>> loaderClass;

    public static final int THROW_BEHAVIOR = 0;
    public static final int EMPTYSTRING_BEHAVIOR = 1;
    public static final int NOOP_BEHAVIOR = 2;

    public Enumeration<String> getKeys() {
        return wrappedBundle.getKeys();
    }

    private RefCapablePropertyResourceBundle(String baseName,
            PropertyResourceBundle wrappedBundle,
            Class<? extends Enum<?>> loaderClass) {
        this.baseName = baseName;
        this.wrappedBundle = wrappedBundle;
        Locale locale = wrappedBundle.getLocale();
        this.loaderClass = loaderClass;
        language = locale.getLanguage();
        country = locale.getCountry();
        variant = locale.getVariant();
        if (language.length() < 1) language = null;
        if (country.length() < 1) country = null;
        if (variant.length() < 1) variant = null;
    }

    /**
     * Same as getString(), but expands System Variables specified in property
     * values like ${sysvarname}.
     *
     * @param key String
     * @param behavior int
     * @return String
     */
    public String getExpandedString(String key, int behavior) {
        String s = getString(key);
        Matcher matcher = sysPropVarPattern.matcher(s);
        int previousEnd = 0;
        StringBuilder sb = new StringBuilder();
        String varName, varValue;
        String condlVal;  // Conditional : value
        while (matcher.find()) {
            varName = matcher.group(1);
            condlVal = ((matcher.groupCount() > 1) ? matcher.group(2) : null);
            varValue = System.getProperty(varName);
            if (condlVal != null) {
                // Replace varValue (the value to be substituted), with
                // the post-:+ portion of the expression.
                varValue = ((varValue == null)
                        ? ""
                        : condlVal.replaceAll("\\Q$" + varName + "\\E\\b",
                                Matcher.quoteReplacement(varValue)));
            }
            if (varValue == null) switch (behavior) {
                case THROW_BEHAVIOR:
                    throw new RuntimeException(
                            "No Sys Property set for variable '"
                            + varName + "' in property value ("
                            + s + ").");
                case EMPTYSTRING_BEHAVIOR:
                    varValue = "";
                    break;
                case NOOP_BEHAVIOR:
                    break;
                default:
                    throw new RuntimeException(
                            "Undefined value for behavior: " + behavior);
            }
            sb.append(s.substring(previousEnd, matcher.start())
                        + ((varValue == null) ? matcher.group() : varValue));
            previousEnd = matcher.end();
        }
        return (previousEnd < 1) ? s
                                 : (sb.toString() + s.substring(previousEnd));
    }

    /**
     * Replaces positional substitution patterns of the form %{\d} with
     * corresponding element of the given subs array. Note that %{\d} numbers
     * are 1-based, so we lok for subs[x-1].
     *
     * @param s String
     * @param subs String[]
     * @param behavior int
     * @return String
     */
    public String posSubst(String s, String[] subs, int behavior) {
        Matcher matcher  = posPattern.matcher(s);
        int previousEnd  = 0;
        StringBuilder sb = new StringBuilder();
        String varValue;
        int varIndex;
        String condlVal;  // Conditional : value
        while (matcher.find()) {
            varIndex = Integer.parseInt(matcher.group(1)) - 1;
            condlVal = ((matcher.groupCount() > 1) ? matcher.group(2) : null);
            varValue = ((varIndex < subs.length) ? subs[varIndex] : null);
            if (condlVal != null) {
                // Replace varValue (the value to be substituted), with
                // the post-:+ portion of the expression.
                varValue = ((varValue == null)
                        ? ""
                        : condlVal.replaceAll("\\Q%" + (varIndex+1) + "\\E\\b",
                                Matcher.quoteReplacement(varValue)));
            }
            // System.err.println("Behavior: " + behavior);
            if (varValue == null) switch (behavior) {
                case THROW_BEHAVIOR:
                    throw new RuntimeException(
                            Integer.toString(subs.length)
                            + " positional values given, but property string "
                            + "contains (" + matcher.group() + ").");
                case EMPTYSTRING_BEHAVIOR:
                    varValue = "";
                    break;
                case NOOP_BEHAVIOR:
                    break;
                default:
                    throw new RuntimeException(
                            "Undefined value for behavior: " + behavior);
            }
            sb.append(s.substring(previousEnd, matcher.start())
                        + ((varValue == null) ? matcher.group() : varValue));
            previousEnd = matcher.end();
        }
        return (previousEnd < 1) ? s
                                 : (sb.toString() + s.substring(previousEnd));
    }

    public String getExpandedString(String key, String[] subs,
            int missingPropertyBehavior, int missingPosValueBehavior) {
        return posSubst(getExpandedString(key, missingPropertyBehavior), subs,
                missingPosValueBehavior);
    }
    public String getString(String key, String[] subs, int behavior) {
        return posSubst(getString(key), subs, behavior);
    }

    /**
     * Just identifies this RefCapablePropertyResourceBundle instance.
     *
     * @return String
     */
    public String toString() {
        return baseName + " for " + language + " / " + country + " / "
            + variant;
    }

    /**
     * Returns value defined in this RefCapablePropertyResourceBundle's
     * .properties file, unless that value is empty. If the value in the
     * .properties file is empty, then this returns the entire contents of the
     * referenced text file.
     *
     * @see ResourceBundle#getString(String)
     * @param key String
     * @return String
     */
    public String getString(String key) {
        String value = wrappedBundle.getString(key);
        if (value.length() < 1) {
            value = getStringFromFile(key);
            // For conciseness and sanity, get rid of all \r's so that \n
            // will definitively be our line breaks.
            if (value.indexOf('\r') > -1)
                value = value.replaceAll("\\Q\r\n", "\n")
                        .replaceAll("\\Q\r", "\n");
            if (value.length() > 0 && value.charAt(value.length() - 1) == '\n')
                value = value.substring(0, value.length() - 1);
        }
        return RefCapablePropertyResourceBundle.toNativeLs(value);
    }

    /**
     * @param inString  Input string with \n definitively indicating desired
     *                  position for line separators.
     * @return  If platform's line-separator is \n, then just returns inString.
     *           Otherwise returns a copy of inString, with all \n's
     *           transformed to the platform's line separators.
     */
    public static String toNativeLs(String inString) {
        return LS.equals("\n") ? inString : inString.replaceAll("\\Q\n", LS);
    }

    /**
     * Use like java.util.ResourceBundle.getBundle(String).
     *
     * ClassLoader is required for our getBundles()s, since it is impossible
     * to get the "caller's" ClassLoader without using JNI (i.e., with pure
     * Java).
     *
     * @see ResourceBundle#getBundle(String)
     * @param baseName String
     * @param loader ClassLoader
     * @param loaderClass Class
     * @return RefCapablePropertyResourceBundle
     */
    public static RefCapablePropertyResourceBundle getBundle(String baseName,
            ClassLoader loader, Class<? extends Enum<?>> loaderClass) {
        return getRef(baseName, ResourceBundle.getBundle(baseName,
                Locale.getDefault(), loader), loaderClass);
    }

    /**
     * Use exactly like java.util.ResourceBundle.get(String, Locale,
     * ClassLoader).
     *
     * @see ResourceBundle#getBundle(String, Locale, ClassLoader)
     * @param baseName String
     * @param locale Locale
     * @param loader ClassLoader
     * @param loaderClass Class
     * @return RefCapablePropertyResourceBundle
     */
    public static RefCapablePropertyResourceBundle
            getBundle(String baseName, Locale locale, ClassLoader loader,
            Class<? extends Enum<?>> loaderClass) {
        return getRef(baseName, ResourceBundle.getBundle(
          baseName, locale, loader), loaderClass);
    }

    /**
     * Return a ref to a new or existing RefCapablePropertyResourceBundle, or
     * throw a MissingResourceException.
     *
     * @param baseName String
     * @param rb ResourceBundle
     * @param loaderClass Class
     * @return RefCapablePropertyResourceBundle
     */
    static private RefCapablePropertyResourceBundle getRef(String baseName,
            ResourceBundle rb, Class<? extends Enum<?>> loaderClass) {
        if (!(rb instanceof PropertyResourceBundle))
            throw new MissingResourceException(
                    "Found a Resource Bundle, but it is a "
                            + rb.getClass().getName(),
                    PropertyResourceBundle.class.getName(), null);
        if (allBundles.containsKey(rb)) return allBundles.get(rb);
        RefCapablePropertyResourceBundle newPRAFP =
                new RefCapablePropertyResourceBundle(baseName,
                        (PropertyResourceBundle) rb, loaderClass);
        allBundles.put(rb, newPRAFP);
        return newPRAFP;
    }

    /**
     * Recursive.
     * For consistency we do not allow paths relative to anything other
     * than root.  Even though we load via Class, baseNames are resolved
     * as if absolute, just like ClassLoader lookups.
     */
    private InputStream getMostSpecificStream(
            String key, String l, String c, String v) {
        // Now that using a Class to look up resource for this special case,
        // absolute paths need to begin with slash.
        final String filePath =
               (baseName.matches("^[^./].*[./].+") ? "/" : "")
                + baseName.replace('.', '/') + '/' + key
                + ((l == null) ? "" : ("_" + l))
                + ((c == null) ? "" : ("_" + c))
                + ((v == null) ? "" : ("_" + v))
                + ".text";
        //System.err.println("Seeking " + filePath + " FOR " + baseName);
        InputStream is = loaderClass.getResourceAsStream(filePath);
        // N.b.  If were using Class.getRes... instead of ClassLoader.getRes...
        // we would need to prefix the path with "/".
        return (is == null && l != null)
            ? getMostSpecificStream(key, ((c == null) ? null : l),
                    ((v == null) ? null : c), null)
            : is;
    }

    private String getStringFromFile(String key) {
        byte[] ba = null;
        int bytesread = 0;
        int retval;
        InputStream  inputStream =
                getMostSpecificStream(key, language, country, variant);
        if (inputStream == null)
            throw new MissingResourceException(
                    "Key '" + key
                    + "' is present in .properties file with no value, yet "
                    + "text file resource is missing",
                    RefCapablePropertyResourceBundle.class.getName(), key);
        try {
            try {
                ba = new byte[inputStream.available()];
            } catch (IOException ioe) {
                throw new MissingResourceException(
                    "Failed to read in value for key '" + key + "': " + ioe,
                    RefCapablePropertyResourceBundle.class.getName(), key);
            } catch (Throwable re) {
                throw new MissingResourceException(
                    "Resource is too big to read in '" + key + "' value in one "
                    + "gulp.\nPlease run the program with more RAM "
                    + "(try Java -Xm* switches).: " + re,
                    RefCapablePropertyResourceBundle.class.getName(), key);
            }

            try {
                while (bytesread < ba.length &&
                        (retval = inputStream.read(
                                ba, bytesread, ba.length - bytesread)) > 0) {
                    bytesread += retval;
                }
            } catch (IOException ioe) {
                throw new MissingResourceException(
                    "Failed to read in value for '" + key + "': " + ioe,
                    RefCapablePropertyResourceBundle.class.getName(), key);
            }
        } finally {
            try {
                inputStream.close();
            } catch (IOException ioe) {
                System.err.println("Failed to close input stream: " + ioe);
            }
        }
        if (bytesread != ba.length) {
            throw new MissingResourceException(
                    "Didn't read all bytes.  Read in "
                      + bytesread + " bytes out of " + ba.length
                      + " bytes for key '" + key + "'",
                    RefCapablePropertyResourceBundle.class.getName(), key);
        }

        try {
            return new String(ba, StandardCharsets.ISO_8859_1);
        } catch (Throwable re) {
            throw new MissingResourceException(
                "Value for key '" + key + "' too big to convert to String.  "
                + "Please run the program with more RAM "
                + "(try Java -Xm* switches).: " + re,
                RefCapablePropertyResourceBundle.class.getName(), key);
        }
    }
}