    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.Iterator;
    025import java.util.Locale;
    026import java.util.Map;
    027import java.util.Objects;
    028
    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 {@code FormatFactory} instances associated with {@code 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 {}<i>argument-number</i><b>(</b>{@code ,}<i>format-name</i><b>
    042 * (</b>{@code ,}<i>format-style</i><b>)?)?</b>{@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 {@code Format}
    048 * matching <i>format-name</i> and <i>format-style</i> is requested from
    049 * {@code formatFactoryInstance}.  If this is successful, the {@code 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 * {@code 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 {@code Format}s, including {@code MessageFormat} and thus
    064 *     {@code ExtendedMessageFormat}, is not guaranteed.</li>
    065 * </ul>
    066 *
    067 * @since 2.4
    068 * @deprecated as of 3.6, use 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    private String toPattern;
    084    private final Map<String, ? extends FormatFactory> registry;
    085
    086    /**
    087     * Create a new ExtendedMessageFormat for the default locale.
    088     *
    089     * @param pattern  the pattern to use, not null
    090     * @throws IllegalArgumentException in case of a bad pattern.
    091     */
    092    public ExtendedMessageFormat(final String pattern) {
    093        this(pattern, Locale.getDefault());
    094    }
    095
    096    /**
    097     * Create a new ExtendedMessageFormat.
    098     *
    099     * @param pattern  the pattern to use, not null
    100     * @param locale  the locale to use, not null
    101     * @throws IllegalArgumentException in case of a bad pattern.
    102     */
    103    public ExtendedMessageFormat(final String pattern, final Locale locale) {
    104        this(pattern, locale, null);
    105    }
    106
    107    /**
    108     * Create a new ExtendedMessageFormat for the default locale.
    109     *
    110     * @param pattern  the pattern to use, not null
    111     * @param registry  the registry of format factories, may be null
    112     * @throws IllegalArgumentException in case of a bad pattern.
    113     */
    114    public ExtendedMessageFormat(final String pattern, final Map<String, ? extends FormatFactory> registry) {
    115        this(pattern, Locale.getDefault(), registry);
    116    }
    117
    118    /**
    119     * Create a new ExtendedMessageFormat.
    120     *
    121     * @param pattern  the pattern to use, not null
    122     * @param locale  the locale to use, not null
    123     * @param registry  the registry of format factories, may be null
    124     * @throws IllegalArgumentException in case of a bad pattern.
    125     */
    126    public ExtendedMessageFormat(final String pattern, final Locale locale, final Map<String, ? extends FormatFactory> registry) {
    127        super(DUMMY_PATTERN);
    128        setLocale(locale);
    129        this.registry = registry;
    130        applyPattern(pattern);
    131    }
    132
    133    /**
    134     * {@inheritDoc}
    135     */
    136    @Override
    137    public String toPattern() {
    138        return toPattern;
    139    }
    140
    141    /**
    142     * Apply the specified pattern.
    143     *
    144     * @param pattern String
    145     */
    146    @Override
    147    public final void applyPattern(final String pattern) {
    148        if (registry == null) {
    149            super.applyPattern(pattern);
    150            toPattern = super.toPattern();
    151            return;
    152        }
    153        final ArrayList<Format> foundFormats = new ArrayList<>();
    154        final ArrayList<String> foundDescriptions = new ArrayList<>();
    155        final StringBuilder stripCustom = new StringBuilder(pattern.length());
    156
    157        final ParsePosition pos = new ParsePosition(0);
    158        final char[] c = pattern.toCharArray();
    159        int fmtCount = 0;
    160        while (pos.getIndex() < pattern.length()) {
    161            switch (c[pos.getIndex()]) {
    162            case QUOTE:
    163                appendQuotedString(pattern, pos, stripCustom);
    164                break;
    165            case START_FE:
    166                fmtCount++;
    167                seekNonWs(pattern, pos);
    168                final int start = pos.getIndex();
    169                final int index = readArgumentIndex(pattern, next(pos));
    170                stripCustom.append(START_FE).append(index);
    171                seekNonWs(pattern, pos);
    172                Format format = null;
    173                String formatDescription = null;
    174                if (c[pos.getIndex()] == START_FMT) {
    175                    formatDescription = parseFormatDescription(pattern,
    176                            next(pos));
    177                    format = getFormat(formatDescription);
    178                    if (format == null) {
    179                        stripCustom.append(START_FMT).append(formatDescription);
    180                    }
    181                }
    182                foundFormats.add(format);
    183                foundDescriptions.add(format == null ? null : formatDescription);
    184                Validate.isTrue(foundFormats.size() == fmtCount);
    185                Validate.isTrue(foundDescriptions.size() == fmtCount);
    186                if (c[pos.getIndex()] != END_FE) {
    187                    throw new IllegalArgumentException(
    188                            "Unreadable format element at position " + start);
    189                }
    190                //$FALL-THROUGH$
    191            default:
    192                stripCustom.append(c[pos.getIndex()]);
    193                next(pos);
    194            }
    195        }
    196        super.applyPattern(stripCustom.toString());
    197        toPattern = insertFormats(super.toPattern(), foundDescriptions);
    198        if (containsElements(foundFormats)) {
    199            final Format[] origFormats = getFormats();
    200            // only loop over what we know we have, as MessageFormat on Java 1.3
    201            // seems to provide an extra format element:
    202            int i = 0;
    203            for (final Iterator<Format> it = foundFormats.iterator(); it.hasNext(); i++) {
    204                final Format f = it.next();
    205                if (f != null) {
    206                    origFormats[i] = f;
    207                }
    208            }
    209            super.setFormats(origFormats);
    210        }
    211    }
    212
    213    /**
    214     * Throws UnsupportedOperationException - see class Javadoc for details.
    215     *
    216     * @param formatElementIndex format element index
    217     * @param newFormat the new format
    218     * @throws UnsupportedOperationException always thrown since this isn't supported by ExtendMessageFormat
    219     */
    220    @Override
    221    public void setFormat(final int formatElementIndex, final Format newFormat) {
    222        throw new UnsupportedOperationException();
    223    }
    224
    225    /**
    226     * Throws UnsupportedOperationException - see class Javadoc for details.
    227     *
    228     * @param argumentIndex argument index
    229     * @param newFormat the new format
    230     * @throws UnsupportedOperationException always thrown since this isn't supported by ExtendMessageFormat
    231     */
    232    @Override
    233    public void setFormatByArgumentIndex(final int argumentIndex, final Format newFormat) {
    234        throw new UnsupportedOperationException();
    235    }
    236
    237    /**
    238     * Throws UnsupportedOperationException - see class Javadoc for details.
    239     *
    240     * @param newFormats new formats
    241     * @throws UnsupportedOperationException always thrown since this isn't supported by ExtendMessageFormat
    242     */
    243    @Override
    244    public void setFormats(final Format[] newFormats) {
    245        throw new UnsupportedOperationException();
    246    }
    247
    248    /**
    249     * Throws UnsupportedOperationException - see class Javadoc for details.
    250     *
    251     * @param newFormats new formats
    252     * @throws UnsupportedOperationException always thrown since this isn't supported by ExtendMessageFormat
    253     */
    254    @Override
    255    public void setFormatsByArgumentIndex(final Format[] newFormats) {
    256        throw new UnsupportedOperationException();
    257    }
    258
    259    /**
    260     * Check if this extended message format is equal to another object.
    261     *
    262     * @param obj the object to compare to
    263     * @return true if this object equals the other, otherwise false
    264     */
    265    @Override
    266    public boolean equals(final Object obj) {
    267        if (obj == this) {
    268            return true;
    269        }
    270        if (obj == null) {
    271            return false;
    272        }
    273        if (!super.equals(obj)) {
    274            return false;
    275        }
    276        if (ObjectUtils.notEqual(getClass(), obj.getClass())) {
    277          return false;
    278        }
    279        final ExtendedMessageFormat rhs = (ExtendedMessageFormat) obj;
    280        if (ObjectUtils.notEqual(toPattern, rhs.toPattern)) {
    281            return false;
    282        }
    283        return !ObjectUtils.notEqual(registry, rhs.registry);
    284    }
    285
    286    /**
    287     * {@inheritDoc}
    288     */
    289    @Override
    290    public int hashCode() {
    291        int result = super.hashCode();
    292        result = HASH_SEED * result + Objects.hashCode(registry);
    293        result = HASH_SEED * result + Objects.hashCode(toPattern);
    294        return result;
    295    }
    296
    297    /**
    298     * Gets a custom format from a format description.
    299     *
    300     * @param desc String
    301     * @return Format
    302     */
    303    private Format getFormat(final String desc) {
    304        if (registry != null) {
    305            String name = desc;
    306            String args = null;
    307            final int i = desc.indexOf(START_FMT);
    308            if (i > 0) {
    309                name = desc.substring(0, i).trim();
    310                args = desc.substring(i + 1).trim();
    311            }
    312            final FormatFactory factory = registry.get(name);
    313            if (factory != null) {
    314                return factory.getFormat(name, args, getLocale());
    315            }
    316        }
    317        return null;
    318    }
    319
    320    /**
    321     * Read the argument index from the current format element
    322     *
    323     * @param pattern pattern to parse
    324     * @param pos current parse position
    325     * @return argument index
    326     */
    327    private int readArgumentIndex(final String pattern, final ParsePosition pos) {
    328        final int start = pos.getIndex();
    329        seekNonWs(pattern, pos);
    330        final StringBuilder result = new StringBuilder();
    331        boolean error = false;
    332        for (; !error && pos.getIndex() < pattern.length(); next(pos)) {
    333            char c = pattern.charAt(pos.getIndex());
    334            if (Character.isWhitespace(c)) {
    335                seekNonWs(pattern, pos);
    336                c = pattern.charAt(pos.getIndex());
    337                if (c != START_FMT && c != END_FE) {
    338                    error = true;
    339                    continue;
    340                }
    341            }
    342            if ((c == START_FMT || c == END_FE) && result.length() > 0) {
    343                try {
    344                    return Integer.parseInt(result.toString());
    345                } catch (final NumberFormatException e) { // NOPMD
    346                    // we've already ensured only digits, so unless something
    347                    // outlandishly large was specified we should be okay.
    348                }
    349            }
    350            error = !Character.isDigit(c);
    351            result.append(c);
    352        }
    353        if (error) {
    354            throw new IllegalArgumentException(
    355                    "Invalid format argument index at position " + start + ": "
    356                            + pattern.substring(start, pos.getIndex()));
    357        }
    358        throw new IllegalArgumentException(
    359                "Unterminated format element at position " + start);
    360    }
    361
    362    /**
    363     * Parse the format component of a format element.
    364     *
    365     * @param pattern string to parse
    366     * @param pos current parse position
    367     * @return Format description String
    368     */
    369    private String parseFormatDescription(final String pattern, final ParsePosition pos) {
    370        final int start = pos.getIndex();
    371        seekNonWs(pattern, pos);
    372        final int text = pos.getIndex();
    373        int depth = 1;
    374        for (; pos.getIndex() < pattern.length(); next(pos)) {
    375            switch (pattern.charAt(pos.getIndex())) {
    376            case START_FE:
    377                depth++;
    378                break;
    379            case END_FE:
    380                depth--;
    381                if (depth == 0) {
    382                    return pattern.substring(text, pos.getIndex());
    383                }
    384                break;
    385            case QUOTE:
    386                getQuotedString(pattern, pos);
    387                break;
    388            default:
    389                break;
    390            }
    391        }
    392        throw new IllegalArgumentException(
    393                "Unterminated format element at position " + start);
    394    }
    395
    396    /**
    397     * Insert formats back into the pattern for toPattern() support.
    398     *
    399     * @param pattern source
    400     * @param customPatterns The custom patterns to re-insert, if any
    401     * @return full pattern
    402     */
    403    private String insertFormats(final String pattern, final ArrayList<String> customPatterns) {
    404        if (!containsElements(customPatterns)) {
    405            return pattern;
    406        }
    407        final StringBuilder sb = new StringBuilder(pattern.length() * 2);
    408        final ParsePosition pos = new ParsePosition(0);
    409        int fe = -1;
    410        int depth = 0;
    411        while (pos.getIndex() < pattern.length()) {
    412            final char c = pattern.charAt(pos.getIndex());
    413            switch (c) {
    414            case QUOTE:
    415                appendQuotedString(pattern, pos, sb);
    416                break;
    417            case START_FE:
    418                depth++;
    419                sb.append(START_FE).append(readArgumentIndex(pattern, next(pos)));
    420                // do not look for custom patterns when they are embedded, e.g. in a choice
    421                if (depth == 1) {
    422                    fe++;
    423                    final String customPattern = customPatterns.get(fe);
    424                    if (customPattern != null) {
    425                        sb.append(START_FMT).append(customPattern);
    426                    }
    427                }
    428                break;
    429            case END_FE:
    430                depth--;
    431                //$FALL-THROUGH$
    432            default:
    433                sb.append(c);
    434                next(pos);
    435            }
    436        }
    437        return sb.toString();
    438    }
    439
    440    /**
    441     * Consume whitespace from the current parse position.
    442     *
    443     * @param pattern String to read
    444     * @param pos current position
    445     */
    446    private void seekNonWs(final String pattern, final ParsePosition pos) {
    447        int len = 0;
    448        final char[] buffer = pattern.toCharArray();
    449        do {
    450            len = StrMatcher.splitMatcher().isMatch(buffer, pos.getIndex());
    451            pos.setIndex(pos.getIndex() + len);
    452        } while (len > 0 && pos.getIndex() < pattern.length());
    453    }
    454
    455    /**
    456     * Convenience method to advance parse position by 1
    457     *
    458     * @param pos ParsePosition
    459     * @return {@code pos}
    460     */
    461    private ParsePosition next(final ParsePosition pos) {
    462        pos.setIndex(pos.getIndex() + 1);
    463        return pos;
    464    }
    465
    466    /**
    467     * Consume a quoted string, adding it to {@code appendTo} if
    468     * specified.
    469     *
    470     * @param pattern pattern to parse
    471     * @param pos current parse position
    472     * @param appendTo optional StringBuilder to append
    473     * @return {@code appendTo}
    474     */
    475    private StringBuilder appendQuotedString(final String pattern, final ParsePosition pos,
    476            final StringBuilder appendTo) {
    477        assert pattern.toCharArray()[pos.getIndex()] == QUOTE :
    478            "Quoted string must start with quote character";
    479
    480        // handle quote character at the beginning of the string
    481        if (appendTo != null) {
    482            appendTo.append(QUOTE);
    483        }
    484        next(pos);
    485
    486        final int start = pos.getIndex();
    487        final char[] c = pattern.toCharArray();
    488        final int lastHold = start;
    489        for (int i = pos.getIndex(); i < pattern.length(); i++) {
    490            if (c[pos.getIndex()] == QUOTE) {
    491                next(pos);
    492                return appendTo == null ? null : appendTo.append(c, lastHold,
    493                        pos.getIndex() - lastHold);
    494            }
    495            next(pos);
    496        }
    497        throw new IllegalArgumentException(
    498                "Unterminated quoted string at position " + start);
    499    }
    500
    501    /**
    502     * Consume quoted string only
    503     *
    504     * @param pattern pattern to parse
    505     * @param pos current parse position
    506     */
    507    private void getQuotedString(final String pattern, final ParsePosition pos) {
    508        appendQuotedString(pattern, pos, null);
    509    }
    510
    511    /**
    512     * Learn whether the specified Collection contains non-null elements.
    513     * @param coll to check
    514     * @return {@code true} if some Object was found, {@code false} otherwise.
    515     */
    516    private boolean containsElements(final Collection<?> coll) {
    517        if (coll == null || coll.isEmpty()) {
    518            return false;
    519        }
    520        for (final Object name : coll) {
    521            if (name != null) {
    522                return true;
    523            }
    524        }
    525        return false;
    526    }
    527}