    001/*
    002 * Licensed to the Apache Software Foundation (ASF) under one or more
    003 * contributor license agreements.  See the NOTICE file distributed with
    004 * this work for additional information regarding copyright ownership.
    005 * The ASF licenses this file to You under the Apache License, Version 2.0
    006 * (the "License"); you may not use this file except in compliance with
    007 * the License.  You may obtain a copy of the License at
    008 *
    009 *      http://www.apache.org/licenses/LICENSE-2.0
    010 *
    011 * Unless required by applicable law or agreed to in writing, software
    012 * distributed under the License is distributed on an "AS IS" BASIS,
    013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    014 * See the License for the specific language governing permissions and
    015 * limitations under the License.
    016 */
    017package org.apache.commons.lang3.text;
    018
    019import java.text.Format;
    020import java.text.MessageFormat;
    021import java.text.ParsePosition;
    022import java.util.ArrayList;
    023import java.util.Collection;
    024import java.util.Locale;
    025import java.util.Map;
    026import java.util.Objects;
    027
    028import org.apache.commons.lang3.LocaleUtils;
    029import org.apache.commons.lang3.ObjectUtils;
    030import org.apache.commons.lang3.Validate;
    031
    032/**
    033 * Extends {@code java.text.MessageFormat} to allow pluggable/additional formatting
    034 * options for embedded format elements.  Client code should specify a registry
    035 * of {@link FormatFactory} instances associated with {@link String}
    036 * format names.  This registry will be consulted when the format elements are
    037 * parsed from the message pattern.  In this way custom patterns can be specified,
    038 * and the formats supported by {@code java.text.MessageFormat} can be overridden
    039 * at the format and/or format style level (see MessageFormat).  A "format element"
    040 * embedded in the message pattern is specified (<b>()?</b> signifies optionality):<br>
    041 * <code>{</code><i>argument-number</i><b>(</b>{@code ,}<i>format-name</i><b>
    042 * (</b>{@code ,}<i>format-style</i><b>)?)?</b><code>}</code>
    043 *
    044 * <p>
    045 * <i>format-name</i> and <i>format-style</i> values are trimmed of surrounding whitespace
    046 * in the manner of {@code java.text.MessageFormat}.  If <i>format-name</i> denotes
    047 * {@code FormatFactory formatFactoryInstance} in {@code registry}, a {@link Format}
    048 * matching <i>format-name</i> and <i>format-style</i> is requested from
    049 * {@code formatFactoryInstance}.  If this is successful, the {@link Format}
    050 * found is used for this format element.
    051 * </p>
    052 *
    053 * <p><b>NOTICE:</b> The various subformat mutator methods are considered unnecessary; they exist on the parent
    054 * class to allow the type of customization which it is the job of this class to provide in
    055 * a configurable fashion.  These methods have thus been disabled and will throw
    056 * {@link UnsupportedOperationException} if called.
    057 * </p>
    058 *
    059 * <p>Limitations inherited from {@code java.text.MessageFormat}:</p>
    060 * <ul>
    061 * <li>When using "choice" subformats, support for nested formatting instructions is limited
    062 *     to that provided by the base class.</li>
    063 * <li>Thread-safety of {@link Format}s, including {@link MessageFormat} and thus
    064 *     {@link ExtendedMessageFormat}, is not guaranteed.</li>
    065 * </ul>
    066 *
    067 * @since 2.4
    068 * @deprecated As of 3.6, use Apache Commons Text
    069 * <a href="https://commons.apache.org/proper/commons-text/javadocs/api-release/org/apache/commons/text/ExtendedMessageFormat.html">
    070 * ExtendedMessageFormat</a> instead
    071 */
    072@Deprecated
    073public class ExtendedMessageFormat extends MessageFormat {
    074    private static final long serialVersionUID = -2362048321261811743L;
    075    private static final int HASH_SEED = 31;
    076
    077    private static final String DUMMY_PATTERN = "";
    078    private static final char START_FMT = ',';
    079    private static final char END_FE = '}';
    080    private static final char START_FE = '{';
    081    private static final char QUOTE = '\'';
    082
    083    /**
    084     * To pattern string.
    085     */
    086    private String toPattern;
    087
    088    /**
    089     * Our registry of FormatFactory.
    090     */
    091    private final Map<String, ? extends FormatFactory> registry;
    092
    093    /**
    094     * Create a new ExtendedMessageFormat for the default locale.
    095     *
    096     * @param pattern  the pattern to use, not null
    097     * @throws IllegalArgumentException in case of a bad pattern.
    098     */
    099    public ExtendedMessageFormat(final String pattern) {
    100        this(pattern, Locale.getDefault());
    101    }
    102
    103    /**
    104     * Create a new ExtendedMessageFormat.
    105     *
    106     * @param pattern  the pattern to use, not null
    107     * @param locale  the locale to use, not null
    108     * @throws IllegalArgumentException in case of a bad pattern.
    109     */
    110    public ExtendedMessageFormat(final String pattern, final Locale locale) {
    111        this(pattern, locale, null);
    112    }
    113
    114    /**
    115     * Create a new ExtendedMessageFormat for the default locale.
    116     *
    117     * @param pattern  the pattern to use, not null
    118     * @param registry  the registry of format factories, may be null
    119     * @throws IllegalArgumentException in case of a bad pattern.
    120     */
    121    public ExtendedMessageFormat(final String pattern, final Map<String, ? extends FormatFactory> registry) {
    122        this(pattern, Locale.getDefault(), registry);
    123    }
    124
    125    /**
    126     * Create a new ExtendedMessageFormat.
    127     *
    128     * @param pattern  the pattern to use, not null.
    129     * @param locale  the locale to use.
    130     * @param registry  the registry of format factories, may be null.
    131     * @throws IllegalArgumentException in case of a bad pattern.
    132     */
    133    public ExtendedMessageFormat(final String pattern, final Locale locale, final Map<String, ? extends FormatFactory> registry) {
    134        super(DUMMY_PATTERN);
    135        setLocale(LocaleUtils.toLocale(locale));
    136        this.registry = registry;
    137        applyPattern(pattern);
    138    }
    139
    140    /**
    141     * {@inheritDoc}
    142     */
    143    @Override
    144    public String toPattern() {
    145        return toPattern;
    146    }
    147
    148    /**
    149     * Apply the specified pattern.
    150     *
    151     * @param pattern String
    152     */
    153    @Override
    154    public final void applyPattern(final String pattern) {
    155        if (registry == null) {
    156            super.applyPattern(pattern);
    157            toPattern = super.toPattern();
    158            return;
    159        }
    160        final ArrayList<Format> foundFormats = new ArrayList<>();
    161        final ArrayList<String> foundDescriptions = new ArrayList<>();
    162        final StringBuilder stripCustom = new StringBuilder(pattern.length());
    163
    164        final ParsePosition pos = new ParsePosition(0);
    165        final char[] c = pattern.toCharArray();
    166        int fmtCount = 0;
    167        while (pos.getIndex() < pattern.length()) {
    168            switch (c[pos.getIndex()]) {
    169            case QUOTE:
    170                appendQuotedString(pattern, pos, stripCustom);
    171                break;
    172            case START_FE:
    173                fmtCount++;
    174                seekNonWs(pattern, pos);
    175                final int start = pos.getIndex();
    176                final int index = readArgumentIndex(pattern, next(pos));
    177                stripCustom.append(START_FE).append(index);
    178                seekNonWs(pattern, pos);
    179                Format format = null;
    180                String formatDescription = null;
    181                if (c[pos.getIndex()] == START_FMT) {
    182                    formatDescription = parseFormatDescription(pattern,
    183                            next(pos));
    184                    format = getFormat(formatDescription);
    185                    if (format == null) {
    186                        stripCustom.append(START_FMT).append(formatDescription);
    187                    }
    188                }
    189                foundFormats.add(format);
    190                foundDescriptions.add(format == null ? null : formatDescription);
    191                Validate.isTrue(foundFormats.size() == fmtCount);
    192                Validate.isTrue(foundDescriptions.size() == fmtCount);
    193                if (c[pos.getIndex()] != END_FE) {
    194                    throw new IllegalArgumentException(
    195                            "Unreadable format element at position " + start);
    196                }
    197                //$FALL-THROUGH$
    198            default:
    199                stripCustom.append(c[pos.getIndex()]);
    200                next(pos);
    201            }
    202        }
    203        super.applyPattern(stripCustom.toString());
    204        toPattern = insertFormats(super.toPattern(), foundDescriptions);
    205        if (containsElements(foundFormats)) {
    206            final Format[] origFormats = getFormats();
    207            // only loop over what we know we have, as MessageFormat on Java 1.3
    208            // seems to provide an extra format element:
    209            int i = 0;
    210            for (final Format f : foundFormats) {
    211                if (f != null) {
    212                    origFormats[i] = f;
    213                }
    214                i++;
    215            }
    216            super.setFormats(origFormats);
    217        }
    218    }
    219
    220    /**
    221     * Throws UnsupportedOperationException - see class Javadoc for details.
    222     *
    223     * @param formatElementIndex format element index
    224     * @param newFormat the new format
    225     * @throws UnsupportedOperationException always thrown since this isn't supported by ExtendMessageFormat
    226     */
    227    @Override
    228    public void setFormat(final int formatElementIndex, final Format newFormat) {
    229        throw new UnsupportedOperationException();
    230    }
    231
    232    /**
    233     * Throws UnsupportedOperationException - see class Javadoc for details.
    234     *
    235     * @param argumentIndex argument index
    236     * @param newFormat the new format
    237     * @throws UnsupportedOperationException always thrown since this isn't supported by ExtendMessageFormat
    238     */
    239    @Override
    240    public void setFormatByArgumentIndex(final int argumentIndex, final Format newFormat) {
    241        throw new UnsupportedOperationException();
    242    }
    243
    244    /**
    245     * Throws UnsupportedOperationException - see class Javadoc for details.
    246     *
    247     * @param newFormats new formats
    248     * @throws UnsupportedOperationException always thrown since this isn't supported by ExtendMessageFormat
    249     */
    250    @Override
    251    public void setFormats(final Format[] newFormats) {
    252        throw new UnsupportedOperationException();
    253    }
    254
    255    /**
    256     * Throws UnsupportedOperationException - see class Javadoc for details.
    257     *
    258     * @param newFormats new formats
    259     * @throws UnsupportedOperationException always thrown since this isn't supported by ExtendMessageFormat
    260     */
    261    @Override
    262    public void setFormatsByArgumentIndex(final Format[] newFormats) {
    263        throw new UnsupportedOperationException();
    264    }
    265
    266    /**
    267     * Check if this extended message format is equal to another object.
    268     *
    269     * @param obj the object to compare to
    270     * @return true if this object equals the other, otherwise false
    271     */
    272    @Override
    273    public boolean equals(final Object obj) {
    274        if (obj == this) {
    275            return true;
    276        }
    277        if (obj == null) {
    278            return false;
    279        }
    280        if (!super.equals(obj)) {
    281            return false;
    282        }
    283        if (ObjectUtils.notEqual(getClass(), obj.getClass())) {
    284          return false;
    285        }
    286        final ExtendedMessageFormat rhs = (ExtendedMessageFormat) obj;
    287        if (ObjectUtils.notEqual(toPattern, rhs.toPattern)) {
    288            return false;
    289        }
    290        return !ObjectUtils.notEqual(registry, rhs.registry);
    291    }
    292
    293    /**
    294     * {@inheritDoc}
    295     */
    296    @Override
    297    public int hashCode() {
    298        int result = super.hashCode();
    299        result = HASH_SEED * result + Objects.hashCode(registry);
    300        result = HASH_SEED * result + Objects.hashCode(toPattern);
    301        return result;
    302    }
    303
    304    /**
    305     * Gets a custom format from a format description.
    306     *
    307     * @param desc String
    308     * @return Format
    309     */
    310    private Format getFormat(final String desc) {
    311        if (registry != null) {
    312            String name = desc;
    313            String args = null;
    314            final int i = desc.indexOf(START_FMT);
    315            if (i > 0) {
    316                name = desc.substring(0, i).trim();
    317                args = desc.substring(i + 1).trim();
    318            }
    319            final FormatFactory factory = registry.get(name);
    320            if (factory != null) {
    321                return factory.getFormat(name, args, getLocale());
    322            }
    323        }
    324        return null;
    325    }
    326
    327    /**
    328     * Read the argument index from the current format element
    329     *
    330     * @param pattern pattern to parse
    331     * @param pos current parse position
    332     * @return argument index
    333     */
    334    private int readArgumentIndex(final String pattern, final ParsePosition pos) {
    335        final int start = pos.getIndex();
    336        seekNonWs(pattern, pos);
    337        final StringBuilder result = new StringBuilder();
    338        boolean error = false;
    339        for (; !error && pos.getIndex() < pattern.length(); next(pos)) {
    340            char c = pattern.charAt(pos.getIndex());
    341            if (Character.isWhitespace(c)) {
    342                seekNonWs(pattern, pos);
    343                c = pattern.charAt(pos.getIndex());
    344                if (c != START_FMT && c != END_FE) {
    345                    error = true;
    346                    continue;
    347                }
    348            }
    349            if ((c == START_FMT || c == END_FE) && result.length() > 0) {
    350                try {
    351                    return Integer.parseInt(result.toString());
    352                } catch (final NumberFormatException ignored) {
    353                    // we've already ensured only digits, so unless something
    354                    // outlandishly large was specified we should be okay.
    355                }
    356            }
    357            error = !Character.isDigit(c);
    358            result.append(c);
    359        }
    360        if (error) {
    361            throw new IllegalArgumentException(
    362                    "Invalid format argument index at position " + start + ": "
    363                            + pattern.substring(start, pos.getIndex()));
    364        }
    365        throw new IllegalArgumentException(
    366                "Unterminated format element at position " + start);
    367    }
    368
    369    /**
    370     * Parse the format component of a format element.
    371     *
    372     * @param pattern string to parse
    373     * @param pos current parse position
    374     * @return Format description String
    375     */
    376    private String parseFormatDescription(final String pattern, final ParsePosition pos) {
    377        final int start = pos.getIndex();
    378        seekNonWs(pattern, pos);
    379        final int text = pos.getIndex();
    380        int depth = 1;
    381        for (; pos.getIndex() < pattern.length(); next(pos)) {
    382            switch (pattern.charAt(pos.getIndex())) {
    383            case START_FE:
    384                depth++;
    385                break;
    386            case END_FE:
    387                depth--;
    388                if (depth == 0) {
    389                    return pattern.substring(text, pos.getIndex());
    390                }
    391                break;
    392            case QUOTE:
    393                getQuotedString(pattern, pos);
    394                break;
    395            default:
    396                break;
    397            }
    398        }
    399        throw new IllegalArgumentException(
    400                "Unterminated format element at position " + start);
    401    }
    402
    403    /**
    404     * Insert formats back into the pattern for toPattern() support.
    405     *
    406     * @param pattern source
    407     * @param customPatterns The custom patterns to re-insert, if any
    408     * @return full pattern
    409     */
    410    private String insertFormats(final String pattern, final ArrayList<String> customPatterns) {
    411        if (!containsElements(customPatterns)) {
    412            return pattern;
    413        }
    414        final StringBuilder sb = new StringBuilder(pattern.length() * 2);
    415        final ParsePosition pos = new ParsePosition(0);
    416        int fe = -1;
    417        int depth = 0;
    418        while (pos.getIndex() < pattern.length()) {
    419            final char c = pattern.charAt(pos.getIndex());
    420            switch (c) {
    421            case QUOTE:
    422                appendQuotedString(pattern, pos, sb);
    423                break;
    424            case START_FE:
    425                depth++;
    426                sb.append(START_FE).append(readArgumentIndex(pattern, next(pos)));
    427                // do not look for custom patterns when they are embedded, e.g. in a choice
    428                if (depth == 1) {
    429                    fe++;
    430                    final String customPattern = customPatterns.get(fe);
    431                    if (customPattern != null) {
    432                        sb.append(START_FMT).append(customPattern);
    433                    }
    434                }
    435                break;
    436            case END_FE:
    437                depth--;
    438                //$FALL-THROUGH$
    439            default:
    440                sb.append(c);
    441                next(pos);
    442            }
    443        }
    444        return sb.toString();
    445    }
    446
    447    /**
    448     * Consume whitespace from the current parse position.
    449     *
    450     * @param pattern String to read
    451     * @param pos current position
    452     */
    453    private void seekNonWs(final String pattern, final ParsePosition pos) {
    454        int len;
    455        final char[] buffer = pattern.toCharArray();
    456        do {
    457            len = StrMatcher.splitMatcher().isMatch(buffer, pos.getIndex());
    458            pos.setIndex(pos.getIndex() + len);
    459        } while (len > 0 && pos.getIndex() < pattern.length());
    460    }
    461
    462    /**
    463     * Convenience method to advance parse position by 1
    464     *
    465     * @param pos ParsePosition
    466     * @return {@code pos}
    467     */
    468    private ParsePosition next(final ParsePosition pos) {
    469        pos.setIndex(pos.getIndex() + 1);
    470        return pos;
    471    }
    472
    473    /**
    474     * Consume a quoted string, adding it to {@code appendTo} if
    475     * specified.
    476     *
    477     * @param pattern pattern to parse
    478     * @param pos current parse position
    479     * @param appendTo optional StringBuilder to append
    480     * @return {@code appendTo}
    481     */
    482    private StringBuilder appendQuotedString(final String pattern, final ParsePosition pos,
    483            final StringBuilder appendTo) {
    484        assert pattern.toCharArray()[pos.getIndex()] == QUOTE :
    485            "Quoted string must start with quote character";
    486
    487        // handle quote character at the beginning of the string
    488        if (appendTo != null) {
    489            appendTo.append(QUOTE);
    490        }
    491        next(pos);
    492
    493        final int start = pos.getIndex();
    494        final char[] c = pattern.toCharArray();
    495        for (int i = pos.getIndex(); i < pattern.length(); i++) {
    496            if (c[pos.getIndex()] == QUOTE) {
    497                next(pos);
    498                return appendTo == null ? null : appendTo.append(c, start,
    499                        pos.getIndex() - start);
    500            }
    501            next(pos);
    502        }
    503        throw new IllegalArgumentException(
    504                "Unterminated quoted string at position " + start);
    505    }
    506
    507    /**
    508     * Consume quoted string only
    509     *
    510     * @param pattern pattern to parse
    511     * @param pos current parse position
    512     */
    513    private void getQuotedString(final String pattern, final ParsePosition pos) {
    514        appendQuotedString(pattern, pos, null);
    515    }
    516
    517    /**
    518     * Learn whether the specified Collection contains non-null elements.
    519     * @param coll to check
    520     * @return {@code true} if some Object was found, {@code false} otherwise.
    521     */
    522    private boolean containsElements(final Collection<?> coll) {
    523        if (coll == null || coll.isEmpty()) {
    524            return false;
    525        }
    526        return coll.stream().anyMatch(Objects::nonNull);
    527    }
    528}