blob: eeea1844224c7c7d37153205d99a00a662ef98a0 [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.sis.util.resources;
import java.net.URL;
import java.io.BufferedInputStream;
import java.io.DataInputStream;
import java.io.IOException;
import java.text.MessageFormat;
import java.util.Map;
import java.util.Enumeration;
import java.util.Locale;
import java.util.MissingResourceException;
import java.util.NoSuchElementException;
import java.util.ResourceBundle;
import java.util.logging.Level;
import java.util.logging.LogRecord;
import java.lang.reflect.Modifier;
import javax.measure.Unit;
import org.opengis.util.CodeList;
import org.opengis.util.InternationalString;
import org.apache.sis.util.Debug;
import org.apache.sis.util.Classes;
import org.apache.sis.util.Localized;
import org.apache.sis.util.Utilities;
import org.apache.sis.util.Exceptions;
import org.apache.sis.util.CharSequences;
import org.apache.sis.util.logging.Logging;
import org.apache.sis.internal.system.Loggers;
import org.apache.sis.internal.util.AutoMessageFormat;
import org.apache.sis.internal.util.MetadataServices;
import org.apache.sis.internal.util.Strings;
import org.apache.sis.measure.RangeFormat;
import org.apache.sis.measure.Range;
/**
* {@link ResourceBundle} implementation accepting integers instead of strings for resource keys.
* Using integers allow implementations to avoid adding large string constants into their
* {@code .class} files and runtime images. Developers still have meaningful labels in their
* code (e.g. {@code MismatchedDimension}) through a set of constants defined in {@code Keys}
* inner classes, with the side-effect of compile-time safety. Because integer constants are
* inlined right into class files at compile time, the declarative classes is not loaded at run time.
*
* <p>Localized resources are fetched by calls to {@link #getString(short)}.
* Arguments can optionally be provided by calls to {@link #getString(short, Object) getString(short, Object, ...)}.
* If arguments are present, then the string will be formatted using {@link MessageFormat},
* completed by some special cases handled by this class. Roughly speaking:</p>
*
* <ul>
* <li>{@link Number}, {@link java.util.Date}, {@link CodeList} and {@link InternationalString} instances
* are localized using the current {@code ResourceBundle} locale.</li>
* <li>Long {@link CharSequence} instances are shortened by {@link CharSequences#shortSentence(CharSequence, int)}.</li>
* <li>{@link Class} and {@link Throwable} instances are summarized.</li>
* </ul>
*
* <div class="section">Thread safety</div>
* The same {@code IndexedResourceBundle} instance can be safely used by many threads without synchronization
* on the part of the caller. Subclasses should make sure that any overridden methods remain safe to call from
* multiple threads.
*
* @author Martin Desruisseaux (IRD, Geomatys)
* @version 1.0
* @since 0.3
* @module
*/
public class IndexedResourceBundle extends ResourceBundle implements Localized {
/**
* Key used in properties map for localizing some aspects of the operation being executed.
* The {@code getResources(Map<?,?>)} methods defined in some sub-classes will look for this property.
*
* @see org.apache.sis.referencing.AbstractIdentifiedObject#LOCALE_KEY
*/
public static final String LOCALE_KEY = "locale";
/**
* Maximum string length for text inserted into another text. This parameter is used by {@link #toArray(Object)}.
* Resource strings are never cut to this length. However, text replacing {@code "{0}"} in a string like
* {@code "Parameter name is {0}"} will be cut to this length.
*/
private static final int MAX_STRING_LENGTH = 200;
/**
* First valid key index.
* We start at 1 rather than 0 in order to keep value 0 available for meaning "no localized message".
*
* @since 0.8
*/
static final int FIRST = 1;
/**
* The path of the binary file containing resources, or {@code null} if there is no resources
* or if the resources have already been loaded. The resources may be a file or an entry in a
* JAR file.
*/
private URL resources;
/**
* The array of resources. Keys are an array index plus {@value #FIRST}. For example the value for key "14" is
* {@code values[13]}. This array will be loaded only when first needed. We should not load it at construction
* time, because some {@code ResourceBundle} objects will never ask for values. This is particularly the case
* for parent resources of {@code Resources_fr_CA}, {@code Resources_en}, {@code Resources_de}, etc. which will
* only be used if a key has not been found in the child resources.
*
* @see #ensureLoaded(String)
*/
@SuppressWarnings("VolatileArrayField") // Okay because we set this field only after the array has been fully constructed.
private volatile String[] values;
/**
* The object to use for formatting messages. This object
* will be constructed only when first needed.
*/
private transient AutoMessageFormat format;
/**
* The key of the last resource requested. If the same resource is requested multiple times,
* knowing its key allows us to avoid invoking the costly {@link MessageFormat#applyPattern}
* method.
*/
private transient short lastKey;
/**
* Constructs a new resource bundle loading data from the given UTF file.
*
* @param resources the path of the binary file containing resources, or {@code null} if
* there is no resources. The resources may be a file or an entry in a JAR file.
*/
protected IndexedResourceBundle(final URL resources) {
this.resources = resources;
}
/**
* Returns a resource bundle of the specified class.
*
* @param <T> the resource bundle class.
* @param base the resource bundle class.
* @param locale the locale, or {@code null} for the default locale.
* @return resources in the given locale.
* @throws MissingResourceException if resources can't be found.
*
* @see Vocabulary#getResources(Locale)
* @see Errors#getResources(Locale)
*/
protected static <T extends IndexedResourceBundle> T getBundle(Class<T> base, Locale locale)
throws MissingResourceException
{
if (locale == null) {
locale = Locale.getDefault();
}
// No caching; we rely on the one implemented in ResourceBundle.
return base.cast(getBundle(base.getName(), locale, base.getClassLoader(), Loader.INSTANCE));
}
/**
* Returns a handler for the constants declared in the inner {@code Keys} class.
* Subclasses should override this method for efficiency, but this is not mandatory.
*
* @return a handler for the constants declared in the inner {@code Keys} class.
*/
protected KeyConstants getKeyConstants() {
Class<?> keysClass = KeyConstants.class;
for (final Class<?> inner : getClass().getClasses()) {
if ("Keys".equals(inner.getSimpleName())) {
keysClass = inner;
break;
}
}
return new KeyConstants(keysClass);
}
/**
* Returns an enumeration of the keys.
*
* @return all keys in this resource bundle.
*/
@Override
public final Enumeration<String> getKeys() {
return new KeyEnum(getKeyConstants().getKeyNames());
}
/**
* The keys as an enumeration. This enumeration needs to skip null values, which
* may occur if the resource bundle is incomplete for that particular locale.
*/
private static final class KeyEnum implements Enumeration<String> {
/** The keys to return. */ private final String[] keys;
/** Index of next key to return. */ private int next;
/** Creates a new enum for the given array of keys. */
KeyEnum(final String[] keys) {
this.keys = keys;
}
/** Returns {@code true} if there is at least one more non-null key. */
@Override public boolean hasMoreElements() {
while (next < keys.length) {
if (keys[next] != null) {
return true;
}
next++;
}
return false;
}
/** Returns the next key. */
@Override public String nextElement() {
while (next < keys.length) {
final String key = keys[next++];
if (key != null) {
return key;
}
}
throw new NoSuchElementException();
}
}
/**
* Lists resources to the specified stream. If a resource has more than one line, only
* the first line will be written. This method is used mostly for debugging purposes.
*
* @param out the destination stream.
* @throws IOException if an output operation failed.
*/
@Debug
public final void list(final Appendable out) throws IOException {
int keyLength = 0;
final String[] keys = getKeyConstants().getKeyNames();
for (final String key : keys) {
if (key != null) {
keyLength = Math.max(keyLength, key.length());
}
}
final String lineSeparator = System.lineSeparator();
final String[] values = ensureLoaded(null);
for (int i=0; i < values.length; i++) {
final String key = keys [i];
final String value = values[i];
if (key != null && value != null) {
int indexCR = value.indexOf('\r'); if (indexCR < 0) indexCR = value.length();
int indexLF = value.indexOf('\n'); if (indexLF < 0) indexLF = value.length();
final String number = String.valueOf(i);
out.append(CharSequences.spaces(5 - number.length()))
.append(number)
.append(": ")
.append(key)
.append(CharSequences.spaces(keyLength - key.length()))
.append(" = ")
.append(value, 0, Math.min(indexCR, indexLF))
.append(lineSeparator);
}
}
}
/**
* Ensures that resource values are loaded. If they are not, loads them immediately.
*
* @param key key for the requested resource, or {@code null} if all resources
* are requested. This key is used mostly for constructing messages.
* @return the resources.
* @throws MissingResourceException if this method failed to load resources.
*/
private String[] ensureLoaded(final String key) throws MissingResourceException {
String[] values = this.values;
if (values == null) synchronized (this) {
values = this.values;
if (values == null) {
/*
* If there is no explicit resources for this instance, inherit the resources
* from the parent. Note that this IndexedResourceBundle instance may still
* differ from its parent in the way dates and numbers are formatted.
*/
if (resources == null) {
/*
* If we get a NullPointerException or ClassCastException here,
* it would be a bug in the way we create the chain of parents.
*/
values = ((IndexedResourceBundle) parent).ensureLoaded(key);
} else {
/*
* Prepares a log record. We will wait for successful loading before
* posting this record. If loading fails, the record will be changed
* into an error record. Note that the message must be logged outside
* the synchronized block, otherwise there is dead locks!
*/
final Locale locale = getLocale(); // Sometime null with IBM's JDK.
final String baseName = getClass().getCanonicalName();
final String methodName = (key != null) ? "getObject" : "getKeys";
final LogRecord record = new LogRecord(Level.FINER, "Loaded resources for {0} from bundle \"{1}\".");
record.setLoggerName(Loggers.LOCALIZATION);
/*
* Loads resources from the UTF file.
*/
try (DataInputStream input = new DataInputStream(new BufferedInputStream(resources.openStream()))) {
values = new String[input.readInt()];
for (int i=0; i<values.length; i++) {
values[i] = input.readUTF();
if (values[i].isEmpty()) {
values[i] = null;
}
}
} catch (IOException exception) {
record.setLevel (Level.WARNING);
record.setMessage(exception.getMessage()); // For administrator, use system locale.
record.setThrown (exception);
Logging.log(IndexedResourceBundle.class, methodName, record);
throw (MissingResourceException) new MissingResourceException(
Exceptions.getLocalizedMessage(exception, locale), // For users, use requested locale.
baseName, key).initCause(exception);
}
/*
* Now, logs the message. This message is provided only in English.
* Note that Locale.getDisplayName() may return different string on
* different Java implementation, but it doesn't matter here since
* we use the result only for logging purpose.
*/
String language = null;
if (locale != null) {
language = locale.getDisplayName(Locale.US);
}
if (language == null || language.isEmpty()) {
language = "<root>";
}
record.setParameters(new String[] {language, baseName});
Logging.log(IndexedResourceBundle.class, methodName, record);
resources = null; // Not needed anymore, let GC do its job.
}
this.values = values;
}
}
return values;
}
/**
* Gets an object for the given key from this resource bundle.
* Returns null if this resource bundle does not contain an
* object for the given key.
*
* @param key the key for the desired object
* @throws NullPointerException if {@code key} is {@code null}
* @return the object for the given key, or null
*/
@Override
protected final Object handleGetObject(final String key) {
/*
* Note: Synchronization is performed by 'ensureLoaded'
*/
final String[] values = ensureLoaded(key);
int keyID;
try {
keyID = Short.parseShort(key);
} catch (NumberFormatException exception) {
/*
* Maybe the full key name has been specified instead. We do that for localized
* LogRecords, for easier debugging if the message has not been properly formatted.
*/
try {
keyID = getKeyConstants().getKeyValue(key);
} catch (ReflectiveOperationException e) {
e.addSuppressed(exception);
Logging.recoverableException(Logging.getLogger(Loggers.LOCALIZATION), getClass(), "handleGetObject", e);
return null; // This is okay as of 'handleGetObject' contract.
}
}
keyID -= FIRST;
return (keyID >= 0 && keyID < values.length) ? values[keyID] : null;
}
/**
* Returns {@code arguments} as an array, and convert some types that are not recognized
* by {@link MessageFormat}. If {@code arguments} is already an array, then that array or
* a copy of that array will be returned. If {@code arguments} is not an array, it will be
* placed in an array of length 1.
*
* <p>All the array elements will be checked for {@link CharSequence}, {@link InternationalString},
* {@link CodeList}, {@link Throwable} or {@link Class} instances.
* All {@code InternationalString} instances will be localized according this resource bundle locale.
* Any characters sequences of length greater than {@link #MAX_STRING_LENGTH} will be shortened using
* the {@link CharSequences#shortSentence(CharSequence, int)} method.</p>
*
* <div class="note"><b>Note:</b>
* If more cases are added, remember to update class and package javadoc.</div>
*
* @param arguments the object to check.
* @return {@code arguments} as an array, eventually with some elements replaced.
*/
final Object[] toArray(final Object arguments) {
Object[] array;
if (arguments instanceof Object[]) {
array = (Object[]) arguments;
} else {
array = new Object[] {arguments};
}
for (int i=0; i<array.length; i++) {
final Object element = array[i];
if (element == null) continue;
Object replacement = element;
if (element instanceof CharSequence) {
CharSequence text = (CharSequence) element;
if (text instanceof InternationalString) {
text = ((InternationalString) element).toString(getLocale());
}
replacement = CharSequences.shortSentence(text, MAX_STRING_LENGTH);
} else if (element instanceof Throwable) {
String message = Exceptions.getLocalizedMessage((Throwable) element, getLocale());
if (message == null) {
message = Classes.getShortClassName(element);
}
replacement = message;
} else if (element instanceof Class<?>) {
replacement = Classes.getShortName(getPublicType((Class<?>) element));
} else if (element instanceof CodeList<?>) {
replacement = MetadataServices.getInstance().getCodeTitle((CodeList<?>) element, getLocale());
} else if (element instanceof Range<?>) {
final Range<?> range = (Range<?>) element;
replacement = new RangeFormat(getLocale(), range.getElementType()).format(range);
} else if (element instanceof Unit<?>) {
String s = element.toString();
if (s.isEmpty()) s = "1";
replacement = s;
} else if (element.getClass().isArray()) {
replacement = Utilities.deepToString(element);
}
/*
* No need to check for Numbers or Dates instances, since they are
* properly formatted in the ResourceBundle locale by MessageFormat.
*/
if (replacement != element) {
if (array == arguments) {
array = array.clone(); // Protect the user-provided array from change.
}
array[i] = replacement;
}
}
return array;
}
/**
* If the given class is not public, returns the first public interface or the first public super-class.
* This is for avoiding confusing the user with private class in message like "Value can not be instance
* of XYZ".
*/
private static Class<?> getPublicType(Class<?> c) {
while (!Modifier.isPublic(c.getModifiers())) {
for (final Class<?> type : c.getInterfaces()) {
if (Modifier.isPublic(type.getModifiers()) && !type.getName().startsWith("java")) {
return type;
}
}
c = c.getSuperclass();
}
return c;
}
/**
* Writes the localized string identified by the given key followed by a colon.
* The way to write the colon depends on the language.
*
* @param key the key for the desired string.
* @param toAppendTo where to write the localized string followed by a colon.
* @throws IOException if an error occurred while writing to the given destination.
*
* @since 0.8
*/
public final void appendLabel(final short key, final Appendable toAppendTo) throws IOException {
toAppendTo.append(getString(key));
if (Locale.FRENCH.getLanguage().equals(getLocale().getLanguage())) {
toAppendTo.append("\u00A0:");
} else {
toAppendTo.append(':');
}
}
/**
* Gets a string for the given key and appends "…" to it.
* This method is typically used for creating menu items.
*
* @param key the key for the desired string.
* @return the string for the given key.
* @throws MissingResourceException if no object for the given key can be found.
*/
public final String getMenuLabel(final short key) throws MissingResourceException {
return getString(key) + '…';
}
/**
* Gets a string for the given key from this resource bundle or one of its parents.
*
* @param key the key for the desired string.
* @return the string for the given key.
* @throws MissingResourceException if no object for the given key can be found.
*/
public final String getString(final short key) throws MissingResourceException {
return getString(String.valueOf(key));
}
/**
* Gets a string for the given key and formats it with the specified argument. The message is
* formatted using {@link MessageFormat}. Calling this method is approximately equivalent to
* calling:
*
* {@preformat java
* String pattern = getString(key);
* Format f = new MessageFormat(pattern);
* return f.format(arg0);
* }
*
* If {@code arg0} is not already an array, it will be placed into an array of length 1. Using
* {@link MessageFormat}, all occurrences of "{0}", "{1}", "{2}" in the resource string will be
* replaced by {@code arg0[0]}, {@code arg0[1]}, {@code arg0[2]}, etc.
*
* @param key the key for the desired string.
* @param arg0 a single object or an array of objects to be formatted and substituted.
* @return the string for the given key.
* @throws MissingResourceException if no object for the given key can be found.
*
* @see #getString(String)
* @see #getString(short,Object,Object)
* @see #getString(short,Object,Object,Object)
* @see MessageFormat
*/
public final String getString(final short key, final Object arg0) throws MissingResourceException {
final String pattern = getString(key);
final Object[] arguments = toArray(arg0);
synchronized (this) {
if (format == null) {
/*
* Constructs a new MessageFormat for formatting the arguments.
*/
format = new AutoMessageFormat(pattern, getLocale());
lastKey = key;
} else if (key != lastKey) {
/*
* Method MessageFormat.applyPattern(…) is costly! We will avoid
* calling it again if the format already has the right pattern.
*/
format.applyPattern(pattern);
lastKey = key;
}
try {
format.configure(arguments);
return format.format(arguments);
} catch (RuntimeException e) {
/*
* Safety against badly implemented toString() method
* in libraries that we do not control.
*/
return "[Unformattable message: " + e + ']';
}
}
}
/**
* Gets a string for the given key and replaces all occurrences of "{0}",
* "{1}", with values of {@code arg0}, {@code arg1}, etc.
*
* @param key the key for the desired string.
* @param arg0 value to substitute for "{0}".
* @param arg1 value to substitute for "{1}".
* @return the formatted string for the given key.
* @throws MissingResourceException if no object for the given key can be found.
*/
public final String getString(final short key,
final Object arg0,
final Object arg1) throws MissingResourceException
{
return getString(key, new Object[] {arg0, arg1});
}
/**
* Gets a string for the given key and replaces all occurrences of "{0}",
* "{1}", with values of {@code arg0}, {@code arg1}, etc.
*
* @param key the key for the desired string.
* @param arg0 value to substitute for "{0}".
* @param arg1 value to substitute for "{1}".
* @param arg2 value to substitute for "{2}".
* @return the formatted string for the given key.
* @throws MissingResourceException if no object for the given key can be found.
*/
public final String getString(final short key,
final Object arg0,
final Object arg1,
final Object arg2) throws MissingResourceException
{
return getString(key, new Object[] {arg0, arg1, arg2});
}
/**
* Gets a string for the given key and replaces all occurrences of "{0}",
* "{1}", with values of {@code arg0}, {@code arg1}, etc.
*
* @param key the key for the desired string.
* @param arg0 value to substitute for "{0}".
* @param arg1 value to substitute for "{1}".
* @param arg2 value to substitute for "{2}".
* @param arg3 value to substitute for "{3}".
* @return the formatted string for the given key.
* @throws MissingResourceException if no object for the given key can be found.
*/
public final String getString(final short key,
final Object arg0,
final Object arg1,
final Object arg2,
final Object arg3) throws MissingResourceException
{
return getString(key, new Object[] {arg0, arg1, arg2, arg3});
}
/**
* Gets a string for the given key and replaces all occurrences of "{0}",
* "{1}", with values of {@code arg0}, {@code arg1}, etc.
*
* @param key the key for the desired string.
* @param arg0 value to substitute for "{0}".
* @param arg1 value to substitute for "{1}".
* @param arg2 value to substitute for "{2}".
* @param arg3 value to substitute for "{3}".
* @param arg4 value to substitute for "{4}".
* @return the formatted string for the given key.
* @throws MissingResourceException if no object for the given key can be found.
*/
public final String getString(final short key,
final Object arg0,
final Object arg1,
final Object arg2,
final Object arg3,
final Object arg4) throws MissingResourceException
{
return getString(key, new Object[] {arg0, arg1, arg2, arg3, arg4});
}
/**
* Gets a localized log record.
*
* @param level the log record level.
* @param key the resource key.
* @return the log record.
*/
public final LogRecord getLogRecord(final Level level, final short key) {
final LogRecord record = new LogRecord(level, getKeyConstants().getKeyName(key));
record.setResourceBundleName(getClass().getName());
record.setResourceBundle(this);
return record;
}
/**
* Gets a localized log record.
*
* @param level the log record level.
* @param key the resource key.
* @param arg0 the parameter for the log message, which may be an array.
* @return the log record.
*/
public final LogRecord getLogRecord(final Level level, final short key,
final Object arg0)
{
final LogRecord record = getLogRecord(level, key);
record.setParameters(toArray(arg0));
return record;
}
/**
* Gets a localized log record.
*
* @param level the log record level.
* @param key the resource key.
* @param arg0 the first parameter.
* @param arg1 the second parameter.
* @return the log record.
*/
public final LogRecord getLogRecord(final Level level, final short key,
final Object arg0,
final Object arg1)
{
return getLogRecord(level, key, new Object[] {arg0, arg1});
}
/**
* Gets a localized log record.
*
* @param level the log record level.
* @param key the resource key.
* @param arg0 the first parameter.
* @param arg1 the second parameter.
* @param arg2 the third parameter.
* @return the log record.
*/
public final LogRecord getLogRecord(final Level level, final short key,
final Object arg0,
final Object arg1,
final Object arg2)
{
return getLogRecord(level, key, new Object[] {arg0, arg1, arg2});
}
/**
* Gets a localized log record.
*
* @param level the log record level.
* @param key the resource key.
* @param arg0 the first parameter.
* @param arg1 the second parameter.
* @param arg2 the third parameter.
* @param arg3 the fourth parameter.
* @return the log record.
*/
public final LogRecord getLogRecord(final Level level, final short key,
final Object arg0,
final Object arg1,
final Object arg2,
final Object arg3)
{
return getLogRecord(level, key, new Object[] {arg0, arg1, arg2, arg3});
}
/**
* Returns the locale specified in the given map, or {@code null} if none.
* Value of unexpected type are ignored.
*
* @param properties the map of properties, or {@code null} if none.
* @return the locale found in the given map, or {@code null} if none.
*
* @since 0.8
*/
protected static Locale getLocale(final Map<?,?> properties) {
if (properties != null) {
final Object candidate = properties.get(LOCALE_KEY);
if (candidate instanceof Locale) {
return (Locale) candidate;
}
}
return null;
}
/**
* Returns a string representation of this object.
* This method is for debugging purposes only.
*
* @return a string representation of this resources bundle.
*/
@Override
public synchronized String toString() {
return Strings.bracket(getClass(), getLocale());
}
}